我正在使用向量库和状态单子在 Haskell 中编写最长的公共子序列算法(以封装Miller O(NP) 算法的非常命令性和可变性)。我已经为某个我需要它的项目用 C 语言编写了它,现在我正在用 Haskell 编写它,以探索如何编写那些与 C 相匹配的具有良好性能的命令式网格行走算法。我用未装箱向量编写的版本对于相同的输入,大约比 C 版本慢 4 倍(并使用正确的优化标志编译 - 我使用了系统时钟时间和Criterion
验证 Haskell 和 C 版本之间的相对时间测量的方法,以及相同的数据类型,包括大输入和小输入)。我一直在试图找出性能问题可能出在哪里,并将感谢反馈 - 我可能在这里遇到了一些众所周知的性能问题,尤其是在我在这里大量使用的向量库中。
在我的代码中,我有一个最常调用的名为 gridWalk 的函数,它也完成了大部分工作。性能下降可能存在,但我无法弄清楚它可能是什么。完整的 Haskell 代码在这里。以下代码片段:
import Data.Vector.Unboxed.Mutable as MU
import Data.Vector.Unboxed as U hiding (mapM_)
import Control.Monad.ST as ST
import Control.Monad.Primitive (PrimState)
import Control.Monad (when)
import Data.STRef (newSTRef, modifySTRef, readSTRef)
import Data.Int
type MVI1 s = MVector (PrimState (ST s)) Int
cmp :: U.Vector Int32 -> U.Vector Int32 -> Int -> Int -> Int
cmp a b i j = go 0 i j
where
n = U.length a
m = U.length b
go !len !i !j| (i<n) && (j<m) && ((unsafeIndex a i) == (unsafeIndex b j)) = go (len+1) (i+1) (j+1)
| otherwise = len
-- function to find previous y on diagonal k for furthest point
findYP :: MVI1 s -> Int -> Int -> ST s (Int,Int)
findYP fp k offset = do
let k0 = k+offset-1
k1 = k+offset+1
y0 <- MU.unsafeRead fp k0 >>= \x -> return $ 1+x
y1 <- MU.unsafeRead fp k1
if y0 > y1 then return (k0,y0)
else return (k1,y1)
{-#INLINE findYP #-}
gridWalk :: Vector Int32 -> Vector Int32 -> MVI1 s -> Int -> (Vector Int32 -> Vector Int32 -> Int -> Int -> Int) -> ST s ()
gridWalk a b fp !k cmp = {-#SCC gridWalk #-} do
let !offset = 1+U.length a
(!kp,!yp) <- {-#SCC findYP #-} findYP fp k offset
let xp = yp-k
len = {-#SCC cmp #-} cmp a b xp yp
x = xp+len
y = yp+len
{-#SCC "updateFP" #-} MU.unsafeWrite fp (k+offset) y
return ()
{-#INLINE gridWalk #-}
-- The function below executes ct times, and updates furthest point as they are found during furthest point search
findSnakes :: Vector Int32 -> Vector Int32 -> MVI1 s -> Int -> Int -> (Vector Int32 -> Vector Int32 -> Int -> Int -> Int) -> (Int -> Int -> Int) -> ST s ()
findSnakes a b fp !k !ct cmp op = {-#SCC findSnakes #-} U.forM_ (U.fromList [0..ct-1]) (\x -> gridWalk a b fp (op k x) cmp)
{-#INLINE findSnakes #-}
我添加了一些成本中心注释,并使用特定 LCS 输入运行分析以进行测试。这是我得到的:
total time = 2.39 secs (2394 ticks @ 1000 us, 1 processor)
total alloc = 4,612,756,880 bytes (excludes profiling overheads)
COST CENTRE MODULE %time %alloc
gridWalk Main 67.5 52.7
findSnakes Main 23.2 27.8
cmp Main 4.2 0.0
findYP Main 3.5 19.4
updateFP Main 1.6 0.0
individual inherited
COST CENTRE MODULE no. entries %time %alloc %time %alloc
MAIN MAIN 64 0 0.0 0.0 100.0 100.0
main Main 129 0 0.0 0.0 0.0 0.0
CAF Main 127 0 0.0 0.0 100.0 100.0
findSnakes Main 141 0 0.0 0.0 0.0 0.0
main Main 128 1 0.0 0.0 100.0 100.0
findSnakes Main 138 0 0.0 0.0 0.0 0.0
gridWalk Main 139 0 0.0 0.0 0.0 0.0
cmp Main 140 0 0.0 0.0 0.0 0.0
while Main 132 4001 0.1 0.0 100.0 100.0
findSnakes Main 133 12000 23.2 27.8 99.9 99.9
gridWalk Main 134 16004000 67.5 52.7 76.7 72.2
cmp Main 137 16004000 4.2 0.0 4.2 0.0
updateFP Main 136 16004000 1.6 0.0 1.6 0.0
findYP Main 135 16004000 3.5 19.4 3.5 19.4
newVI1 Main 130 1 0.0 0.0 0.0 0.0
newVI1.\ Main 131 8004 0.0 0.0 0.0 0.0
CAF GHC.Conc.Signal 112 0 0.0 0.0 0.0 0.0
CAF GHC.IO.Encoding 104 0 0.0 0.0 0.0 0.0
CAF GHC.IO.Encoding.Iconv 102 0 0.0 0.0 0.0 0.0
CAF GHC.IO.Handle.FD 95 0 0.0 0.0 0.0 0.0
如果我正确解释分析输出(并假设分析没有太多失真),gridWalk
则需要大部分时间,但主要功能cmp
和findYP
执行繁重工作的主要功能gridWalk
似乎在分析报告中花费的时间很少。那么,也许瓶颈在于函数用来调用的forM_
包装器?堆配置文件看起来也很干净:findSnakes
gridWalk
阅读核心,什么都没有真正跳出来。我认为内部循环中的某些值可能会被装箱,但我没有在核心中发现它们。我希望性能问题是由于我错过了一些简单的事情。
更新
根据@DanielFischer 的建议,我将forM_
of替换Data.Vector.Unboxed
为Control.Monad
infindSnakes
函数,将 C 版本的性能从 4 倍提高到 2.5 倍。如果您想尝试一下,Haskell 和 C 版本现在发布在这里。
我仍在挖掘核心,看看瓶颈在哪里。gridWalk
是最常被调用的函数,为了使其表现良好,lcsh
应该将whileM_
循环减少为条件检查和内联findSnakes
代码的良好迭代内部循环。我怀疑在汇编中,whileM_
循环不是这种情况,但是由于我对翻译核心以及在汇编中定位名称损坏的 GHC 函数不是很了解,我想这只是耐心地解决问题直到我想通了。同时,如果有任何关于性能修复的指示,我们将不胜感激。
我能想到的另一种可能性是函数调用期间堆检查的开销。正如分析报告中所见,gridWalk
被调用了 16004000 次。假设堆检查需要 6 个周期(我猜它会更少,但我们仍然假设),在 3.33GHz 的机器上进行 96024000 个周期大约是 0.02 秒。
此外,一些性能数据:
Haskell code (GHC 7.6.1 x86_64)
forM_
:修复前大约是 0.25 秒。
time ./T
1
real 0m0.150s
user 0m0.145s
sys 0m0.003s
C code (gcc 4.7.2 x86_64)
:
time ./test
1
real 0m0.065s
user 0m0.063s
sys 0m0.000s
更新 2:
更新的代码在这里。使用STUArray
也不会改变数字。性能大约是 1.5 倍Mac OS X (x86_64,ghc7.6.1)
,与@DanielFischer 在 Linux 上的报告非常相似。
哈斯克尔代码:
$ time ./Diff
1
real 0m0.087s
user 0m0.084s
sys 0m0.003s
C代码:
$ time ./test
1
real 0m0.056s
user 0m0.053s
sys 0m0.002s
一看cmm
,调用是尾递归的,被 变成循环llvm
。但是每次新的迭代似乎也分配了调用堆检查的新值,因此,可能解释了性能上的差异。我必须考虑如何以这样的方式编写尾递归,以便在迭代中不分配任何值,从而避免堆检查和分配开销。