导管管道有奇怪的执行时间

Conduit pipeline has strange execution times

我正在测试这个递归 Haskell 函数的性能,它重复对无限列表的前 100000000 个整数求和(使用 Conduit 管道)并打印每次执行的经过时间:

import Conduit
import Data.Time.Clock

evaluate_listC 0 = return ()
evaluate_listC i = do
    startTime <- getCurrentTime
    print $ runConduitPure $ yieldMany [1..] .| takeC 100000000 .| sumC
    endTime <- getCurrentTime
    print $ diffUTCTime endTime startTime
    evaluate_listC (i-1)

编译(使用 -O 标志)和 运行 代码,并将函数迭代 10 次,我获得以下执行时间:

38.2066878s
4.3696857s
1.3367605s
0.9950032s
0.9399968s
0.9039936s
0.9079987s
0.9119587s
0.9090151s
0.8749654s

为什么第一次迭代(以及第二次)花费更多时间而后面的迭代速度快得令人难以置信?

正如我在评论中提到的,我无法复制这些缓慢的性能数字,但我很确定我知道发生了什么。如果您提供一些额外的细节让我可以重现这个问题,我可以更新答案。

最有可能的是,列表 [1..](或者可能涉及此列表的一些更大的表达式)正在 "lifted" 作为顶级的常量应用形式 (CAF)。由于列表是在第一次迭代期间生成的,因此它作为 "permanent" 堆对象保留以供将来迭代使用。

第一次迭代在 part 中花费了很长时间,因为它正在分配和生成列表,尽管由于 GHC 的 "bump allocator",分配非常快,并且实际生成该列表可能只需要几秒钟。大部分时间可能都花在了垃圾收集上。 GC 时间与 "important" 需要从 bump 分配器中拯救(复制)的东西的大小成比例,并且您正在此处构建一个大的永久列表。

以后的迭代要快得多,因为它们可以 运行 对现有列表进行 Conduit 求和。这可能涉及对中间结果的一些分配,但它们中的大多数不会留下来,因此 GC 少得多并且迭代很快。

第二次和第三次迭代比后面的迭代慢一点的原因与GHC的分代垃圾收集器有关。最初,永久大列表和其他 semi-permanent(例如,仅在短时间内或当前迭代中需要)堆对象都被复制出 bump 分配器。进一步的垃圾收集涉及 re-copying 相同的永久列表,同时允许收集过期的 semi-permanent 对象。最终,列表被提升到下一代,而所有 non-list 对象都留在第一代。

一旦永久列表和semi-permanent "other objects"完全分离到不同的代中,在第一代GC时不再需要重新复制列表,并且迭代时间稳定在大约一秒钟。