关于性能:如何在Haskell中编写高效的动态编程算法?

How does one write efficient Dynamic Programming algorithms in Haskell?

我一直在哈斯克尔玩动态编程。实际上,我在这个主题上看到的每一个教程都给出了基于记忆化和数组类型的惰性的、非常优雅的算法。在这些例子的启发下,我编写了以下算法作为测试:

1
2
3
4
5
6
7
8
9
10
-- pascal n returns the nth entry on the main diagonal of pascal's triangle
-- (mod a million for efficiency)
pascal :: Int -> Int
pascal n  = p ! (n,n) where
           p = listArray ((0,0),(n,n)) [f (i,j) | i <- [0 .. n], j <- [0 .. n]]

           f :: (Int,Int) -> Int
           f (_,0) = 1
           f (0,_) = 1
           f (i,j) = (p ! (i, j-1) + p ! (i-1, j)) `mod` 1000000

我唯一的问题是效率。即使使用GHC的-O2,这个程序也需要1.6秒来计算EDCOX1×0Ω,比等效的未优化C++程序慢约160倍。而这种差距只会随着输入量的增加而扩大。

似乎我已经尝试了上述代码的每一种可能的排列方式,以及像数据memocombinator库这样的建议备选方案,它们都有相同或更差的性能。我没有尝试过的一件事是圣莫纳德,我确信它可以使程序运行的速度比C版本稍慢。但我真的很想用惯用的haskell来写,我不明白为什么惯用的版本效率这么低。我有两个问题:

  • 为什么上面的代码效率这么低?它看起来像是一个简单的矩阵迭代,每个条目都有一个算术运算。显然,哈斯克尔在幕后做了一些我不理解的事情。

  • 有没有一种方法可以让它更高效(最多是C程序运行时的10-15倍),而不牺牲它的无状态递归公式(相对于使用圣莫纳德中可变数组的实现而言)?

  • 谢谢。

    编辑:使用的数组模块是标准数据。数组


    嗯,算法可以设计得更好一点。使用Vector包,并且一次只在内存中保留一行,我们可以以不同的方式获得惯用的内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {-# LANGUAGE BangPatterns #-}
    import Data.Vector.Unboxed
    import Prelude hiding (replicate, tail, scanl)

    pascal :: Int -> Int
    pascal !n = go 1 ((replicate (n+1) 1) :: Vector Int) where
      go !i !prevRow
        | i <= n    = go (i+1) (scanl f 1 (tail prevRow))
        | otherwise = prevRow ! n
      f x y = (x + y) `rem` 1000000

    这将非常严格地进行优化,尤其是因为Vector包包含一些相当巧妙的技巧,可以透明地优化以惯用风格编写的数组操作。


    诀窍是考虑如何立即编写整个该死的算法,然后使用未装箱的向量作为您的备份数据类型。例如,以下在我的计算机上运行的速度大约是您的代码的20倍:

    1
    2
    3
    4
    5
    6
    7
    8
    import qualified Data.Vector.Unboxed as V

    combine :: Int -> Int -> Int
    combine x y = (x+y) `mod` 1000000

    pascal n = V.last $ go n where
        go 0 = V.replicate (n+1) 1
        go m = V.scanl1 combine (go (m-1))

    然后我写了两个main函数,分别在10.42s0.54s中调用,参数为4000。当然,正如我确信你知道的,它们都会被吹出水面(0.00s版本),使用更好的算法:

    1
    2
    3
    4
    pascal' :: Integer -> Integer
    pascal :: Int -> Int
    pascal' n = product [n+1..n*2] `div` product [2..n]
    pascal = fromIntegral . (`mod` 1000000) . pascal' . fromIntegral


    1 Why is the above code so inefficient? It seems like a straightforward iteration through a matrix, with an arithmetic operation at each entry. Clearly Haskell is doing something behind the scenes I don't understand.

    问题是代码向数组写入thunk。然后,当读取条目(n,n)时,thunk的计算会再次跳到数组上,直到最终找到不需要进一步递归的值为止。这会导致很多不必要的分配和效率低下。

    C++代码没有这个问题,这些值被直接写入和读取,而不需要进一步的评估。就像发生在一个STUArray上一样。做

    1
    2
    3
    4
    5
    6
    7
    8
    p = runSTUArray $ do
        arr <- newArray ((0,0),(n,n)) 1
        forM_ [1 .. n] $ \i ->
            forM_ [1 .. n] $ \j -> do
                a <- readArray arr (i,j-1)
                b <- readArray arr (i-1,j)
                writeArray arr (i,j) $! (a+b) `rem` 1000000
        return arr

    真的很难看?

    2 Is there a way to make it much more efficient (at most 10-15 times the runtime of a C program) without sacrificing its stateless, recursive formulation (vis-a-vis an implementation using mutable arrays in the ST Monad)?

    我不知道。但可能有。

    附录:

    一旦使用了STUArrays或unboxed Vectors,与等效的C实现仍然存在显著差异。原因是GCC用乘法、移位和减法(即使没有优化)的组合来代替%,因为模是已知的。在哈斯克尔也这样做(因为GHC还没有这样做)。

    1
    2
    3
    4
    5
    -- fast modulo 1000000
    -- for nonnegative Ints < 2^31
    -- requires 64-bit Ints
    fastMod :: Int -> Int
    fastMod n = n - 1000000*((n*1125899907) `shiftR` 50)

    获取与C相同的haskell版本。