【问题标题】:Conduit pipeline has strange execution times管道管道有奇怪的执行时间
【发布时间】:2019-06-05 22:15:16
【问题描述】:

我正在测试这个递归 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

为什么第一次迭代(以及第二次)需要更多时间,而接下来的迭代速度却快得令人难以置信?

【问题讨论】:

  • 一个大组件可能正在缓存;第一次迭代必须实际计算无限列表的前 100000000 个项,但它们会在接下来的迭代中保留在内存中。
  • 有些可疑。我使用takeC (100000000+i) 试图避免缓存(没有成功)。使用-O0,我得到了运行在约 5MB 上的慢代码(每个循环约 18 秒)。使用-O2,使用高达 5GB (!!!) 的常驻内存,我得到了更快的循环,如上所示(第一个 10 秒,第二个 0.5 秒)。我不得不使用Int 而不是默认的Integer 来防止死于OOM。看起来像缓存,但在这种情况下缓存是一个非常错误的优化。
  • 您使用的是什么确切的测试程序/编译器标志,以及 GHC 和 Conduit 的哪个版本为您提供了这些数字?使用 GHC 8.6.4(使用 -O2)和 Conduit 1.3.1.1,以及主程序 main = evaluate_listC 10,我看到第一次迭代在恒定内存中运行,并在 2 秒内完成,随后的迭代使用记忆值(即,全部在几微秒内运行)。降级到 Conduit 1.3.0(带有 Conduit 便利模块的最早版本),第一次迭代减慢到 4.2 秒,但我不知道如何让它比这更慢。

标签: haskell conduit


【解决方案1】:

正如我在评论中提到的,我无法复制这些缓慢的性能数字,但我很确定我知道发生了什么。如果您提供一些允许我复制问题的其他详细信息,我可以更新答案。

最有可能的是,列表[1..](或可能涉及此列表的一些更大的表达式)正在作为常量应用形式(CAF)被“提升”到顶层。由于列表是在第一次迭代期间生成的,因此它会作为“永久”堆对象保留以供将来迭代使用。

第一次迭代在部分中需要很长时间,因为它正在分配和生成列表,尽管由于 GHC 的“bump allocator”,分配非常快,实际上生成列表可能只需要几秒钟。大部分时间可能都花在垃圾收集上。 GC 时间与需要从凹凸分配器中拯救(复制)的“重要”内容的大小成比例,您正在这里构建一个大的、永久的列表。

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

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

一旦永久列表和半永久“其他对象”完全分离到不同的代,在第一代的 GC 期间不再需要重新复制列表,迭代时间稳定到一秒左右。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2019-09-16
    • 2019-08-12
    • 2023-01-17
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-01-01
    • 2021-03-10
    相关资源
    最近更新 更多