【问题标题】:Performance issue with parallel computation in HaskellHaskell 中并行计算的性能问题
【发布时间】:2018-07-17 23:52:31
【问题描述】:

我正在比较运行相同计算的两个 haskell 程序的性能。

第一个是顺序的:

main :: IO()
main = putStr $ unlines . map (show . solve) $ [100..107]
  where solve x = pow x (10^7) (982451653)

第二个使用Control.Parallel.Strategies:

import Control.Parallel.Strategies

main :: IO()
main = putStr $ unlines . parMap rdeepseq (show . solve) $ [100..107]
  where solve x = pow x (10^7) (982451653)

在这两种情况下,powmodular exponentiation 天真地实现为:

pow :: Int -> Int -> Int -> Int
pow a 0 m = 1
pow a b m = a * (pow a (b-1) m) `mod` m

按预期使用 100% 的 CPU,顺序程序在大约 3 秒内运行。

$ stack ghc seq.hs -- -O2
$ \time -f "%e s - %P" ./seq > /dev/null
2.96 s - 100%

当限制为单核时,并行程序在 100% CPU 的情况下也可以在大约 3 秒内运行。

$ stack ghc par.hs -- -O2 -threaded
$ \time -f "%e s - %P" ./par +RTS -N1 > /dev/null
3.14 s - 99%

但是当我在 4 核上运行它时,并没有观察到预期的性能提升:

$ \time -f "%e s - %P" ./par +RTS -N4 > /dev/null
3.31 s - 235%

更令人惊讶的是,顺序程序在多个内核上运行时使用超过 100% 的 CPU:

$ stack ghc seq.hs -- -O2 -threaded
$ \time -f "%e s - %P" ./seq +RTS -N4 > /dev/null
3.26 s - 232%

如何解释这些结果?


编辑 - 根据@RobertK 和@Yuras 的建议,我将rdeeseq 替换为rpar,它确实解决了最初的问题。但是,性能仍然比我预期的要差很多:

$ stack ghc par.hs -- -O2 -threaded
$ \time -f "%e s - %P" ./par +RTS -N1 > /dev/null
3.12 s - 99%
$ \time -f "%e s - %P" ./par +RTS -N4 > /dev/null
1.91 s - 368%

即使 4 个内核平均运行超过 90% 的时间,执行时间也几乎没有除以 2。

此外,线程范围图的某些部分看起来非常连续:

【问题讨论】:

    标签: haskell parallel-processing


    【解决方案1】:

    首先,rdeepseq 似乎是buggy。尝试运行./seq +RTS -N4 -s,您将不会看到任何火花。这就是为什么您在 4 核上看不到任何加速的原因。请改用rnf x ‘pseq‘ return x

    还要注意+RTS -s 输出中的 GC 静态。实际上 GC 占用了大部分 CPU。使用-N4,您有 4 个并行 GC 运行,它们需要更多时间。这就是为什么顺序程序在 4 个内核上占用更多 CPU 的原因。基本上你有 3 个 GC 线程在自旋锁中空闲等待同步。通过在繁忙的循环中吃 CPU,什么都不做。尝试使用 -qn1 选项限制并行 GC 线程的数量。

    关于性能提升。你不应该期望完美的缩放。另外我认为你有 1 个失败的火花——它被并行评估,但它的结果没有被使用。

    补充:与您在 cmets 中链接的 python 实现相比,我看到您在 haskell 中使用了完全不同的算法。接下来是或多或少类似的方法(需要BangPatterns):

    pow :: Int -> Int -> Int -> Int
    pow a b m = go 1 b
      where
      go !r 0 = r
      go r b' = go ((r * a) `mod` m) (pred b')
    

    您的原始算法使用堆栈来构建结果,因此它受 GC 约束,而不是受实际计算约束。所以你看不到很大的加速。有了新的,我看到了 3 倍的加速(我不得不增加工作量才能看到加速,因为算法变得太慢了)。

    【讨论】:

    • 谢谢,用rpar 替换redeepseq 修复了它。但是,性能提升仍然远低于我的预期:-N1 为 3.1 秒,-N4 为 1.9 秒。关于顺序程序,我不明白为什么在没有要收集的东西时 GC 会使用额外的 132%。
    • 我相信 GC 线程在 GC 可以启动之前一直处于休眠状态,它们不会陷入忙碌的循环中。
    • 感谢您的编辑。我实际上选择了运行少量的长时间计算,以便程序可以实现接近完美的缩放。例如,this python code 在 4 个内核上的比例因子为 3.6。
    • @RobertK 当 GC 正在进行时,他们正处于一个繁忙的循环中,但是所有 GC 线程的工作都不够。见那里:github.com/ghc/ghc/blob/…
    • rdeepseq 没有问题。这个问题实际上是由 rparWith 的错误实现引起的,它允许优化器把它搞砸。它在主分支中修复。不知道有没有发布。
    【解决方案2】:

    我不相信你的平行例子是平行的。 parMap 接受一个策略,而您的策略只是告诉它执行 deepseq。您需要将此策略与定义并行行为的策略相结合,例如rpar。您正在告诉 haskell '执行此地图,使用此策略',而现在您的策略没有定义任何并行行为。

    还要确保编译程序时指定 -rtsopts 标志(我不知道堆栈是否会为您执行此操作,但 ghc 需要它来启用运行时选项)。

    【讨论】:

    • 谢谢,用rpar 替换redeepseq 修复了它。但是,性能提升仍然远低于我的预期:-N1 为 3.1 秒,-N4 为 1.9 秒。我将编辑我的问题以包含相关信息。
    • Haskell 是一种惰性语言。您是在告诉它“并行评估”,它只会根据需要对其进行评估。它可能会返回一个未完全评估的表达式,从而将工作留给“主”线程。 stackoverflow.com/questions/6872898/… 您需要将 rpar 策略与rdeepseq 结合起来,这样haskell 会将其解释为“并行,将其评估为正常形式”。组合策略时考虑这个函数:hackage.haskell.org/package/parallel-3.2.2.0/docs/…
    • Simon Marlow 有一本关于并行和并发函数式编程的优秀书籍web.archive.org/web/20171207155221/http://… 它很好地解释了所有这些行为。一旦你让它正常工作,你就可以研究粒度控制,确保并行任务足够大。如果任务很小,那么创建火花的工作可能会主导完成的整个工作。 @文森特
    • 感谢您提供所有这些信息!我使用rpar `dot` rdeepseq 作为策略并在parMap 之前和之后添加force(来自Control.DeepSeq)以确保haskell 知道所有需要计算的值。但是,我没有注意到任何改进,threadscope graph 看起来非常相似。
    • 我建议研究粒度控制,继续前进。如果您的列表包含 10 000 个元素并且计算相当简单,则您不想创建 10 000 个火花。也许您希望生成 1000 个火花,每个火花计算 100 个顺序映射?这需要一些试验和错误才能找到一个好的粒度。此外,您可以尝试关闭垃圾收集器(通过为其分配大量空间),同时尝试并行策略。 @文森特
    猜你喜欢
    • 1970-01-01
    • 2021-03-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多