【发布时间】:2017-12-13 00:54:14
【问题描述】:
在处理一个竞争性编程问题时,我发现了一个有趣的问题,它大大降低了我的一些代码的性能。经过大量实验后,我设法将问题简化为以下最小示例:
module Main where
main = interact handle
handle :: String -> String
-- handle s = show $ sum l
-- handle s = show $ length l
-- handle s = show $ seq (length l) (sum l)
where
l = [0..10^8] :: [Int]
如果您单独取消注释每个注释行,使用ghc -O2 test.hs 编译并使用time ./test > /dev/null 运行,您应该会得到如下内容:
对于sum l:
0.02user 0.00system 0:00.03elapsed 93%CPU (0avgtext+0avgdata 3380maxresident)k
0inputs+0outputs (0major+165minor)pagefaults 0swaps
对于length l:
0.02user 0.00system 0:00.02elapsed 100%CPU (0avgtext+0avgdata 3256maxresident)k
0inputs+0outputs (0major+161minor)pagefaults 0swaps
对于seq (length l) (sum l):
5.47user 1.15system 0:06.63elapsed 99%CPU (0avgtext+0avgdata 7949048maxresident)k
0inputs+0outputs (0major+1986697minor)pagefaults 0swaps
看看峰值内存使用量的巨大增长。这在一定程度上是有道理的,因为 sum 和 length 都可以懒惰地将列表作为流消耗,而 seq 将触发对整个列表的评估,然后必须存储该列表。但是seq 版本的代码只使用了 8 GB 的内存来处理一个仅包含 400 MB 实际数据的列表。 Haskell 列表的纯函数性质可以解释一些小的常数因素,但内存增加 20 倍似乎是无意的。
这种行为可以由许多事情触发。也许最简单的方法是使用Control.DeepSeq 中的force,但我最初遇到这种情况的方式是在使用Data.Array.IArray(我只能使用标准库)并尝试从列表构造数组时。 Array 的实现是一元的,因此强制对构造它的列表进行评估。
如果有人对这种行为的根本原因有任何见解,我很想知道为什么会发生这种情况。当然,我也很感谢有关如何避免此问题的任何建议,请记住,在这种情况下我必须只使用标准库,并且每个 Array 构造函数都会采用并最终强制一个列表。
我希望你和我一样觉得这个问题很有趣,但希望不那么令人困惑。
编辑: user2407038 的评论让我意识到我忘记发布分析结果。我已经尝试分析此代码,分析器只是声明 100% 的分配是在handle.l 中执行的,所以似乎任何强制评估列表的东西都会使用大量内存。正如我上面提到的,使用来自Control.DeepSeq 的force 函数,构造一个Array,或者任何其他强制列表的东西都会导致这种行为。我很困惑为什么它需要 8 GB 的内存来计算包含 400 MB 数据的列表。即使列表中的每个元素都需要两个 64 位指针,这仍然只是 5 的因数,我认为 GHC 将能够做一些比这更有效的事情。如果不是,这对于Array 包来说是一个明显的瓶颈,因为构造任何数组本质上都需要我们分配比数组本身更多的内存。
所以,最终:有谁知道为什么强制列表需要如此大量的内存,而这会带来如此高的性能成本?
编辑: user2407038 提供了指向非常有用的GHC Memory Footprint 参考的链接。这准确地解释了一切的数据大小,几乎完全解释了巨大的开销:[Int] 被指定为需要 5N+1 个字的内存,每个字 8 个字节,每个元素 40 个字节。在此示例中,建议使用 4 GB,占总峰值使用量的一半。然后很容易相信 sum 的评估会添加一个类似的因素,所以这回答了我的问题。
感谢所有评论者的帮助。
编辑:正如我上面提到的,我最初遇到了这种行为,为什么要构造Array。在对GHC.Arr 进行了一些研究之后,我发现了我认为在构造数组时这种行为的根本原因:构造函数折叠列表以在 ST monad 中编写一个程序,然后运行该程序。显然 ST 在完全组合之前无法执行,在这种情况下,ST 构造将很大并且与输入的大小呈线性关系。为了避免这种行为,我们必须以某种方式修改构造函数,以便在将元素添加到 ST 时从列表中流式传输元素。
【问题讨论】:
-
您已经确定了这种行为的根本原因:“sum 和 length 都可以将列表作为流懒惰地消耗,而 seq 将触发整个列表的评估,然后必须被存储”。如果您想知道分配的确切来源是什么,您应该使用 GHCs 分析器来分析程序,而不是
time,这显然是相当初级的。没有看到实际的程序就不可能说如何避免它;在这个简单的例子中,显然是使用sum l而不是seq (length l) (sum l)。 -
感谢您的建议。我忘了提到我已经这样做了。我将编辑帖子以使其更清晰,但分析显示在计算
handle.l时执行了 100% 的分配。这里的问题是,我看不出需要 8 GB 的工作内存来计算 400 MB 的列表可能会做什么。 -
我的粗略估计如下所示:您有 1e8 个元素/列表节点。每个列表节点都包含一个指向类型信息的指针(8 个字节)、一个构造函数标记(可能也是 8 个字节,因为对齐)和一个指向实际
Int值的指针(8 个字节)。每个Int都包含一个指向类型信息的指针(8 个字节)和数值(8 个字节)。那是1e8 * (8 * 5) / 1e9 = 1.3GB 的数据,没有考虑惰性求值。 -
您会期望简单地显示列表至少占用
4*400MB的空间(请参阅here);但你不是简单地表现它。这似乎是您误解的根源;代表 400MB 数据的列表根本不会占用 400MB 空间。如果您确实需要流式传输这种大小的数据,则几乎可以肯定需要使用流式抽象来获得所需的性能。 -
对不起,如果我不清楚。我知道开销,只是我希望开销小于 20。您链接的内存占用页面非常有帮助,我将保留它以备将来使用,并且几乎可以回答我的问题:它指定
[Int]是 5 N + 1 个单词。在 64 位拱门上每个字 8 个字节,每个元素 40 个字节,所以我们希望我的 10^8 列表为 4 GB。这仍然只是我在实践中看到的一半,但足以满足我的好奇心。