在处理竞争性编程问题时,我发现了一个有趣的问题,大大降低了我的一些代码的性能。经过多次实验,我设法将问题简化为以下最小的例子:
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
看看峰值内存使用量的大幅增加。这有点意义,因为总和和长度当然可以懒惰地将列表作为流使用,而seq
将触发整个列表的评估,然后必须存储。但代码的seq
版本仅使用8 GB的内存来处理仅包含400 MB实际数据的列表。 Haskell列表的纯函数性质可以解释一些小的常数因子,但内存增加20倍似乎是无意识的。
这种行为可以通过许多方式触发。也许最简单的方法是使用force
中的Control.DeepSeq
,但我最初遇到此问题的方式是使用Data.Array.IArray
(我只能使用标准库)并尝试构建数组从列表中。 Array
的实现是monadic,因此强制评估正在构建它的列表。
如果有人对此行为的根本原因有任何了解,我会非常有兴趣了解为什么会发生这种情况。我当然也赞赏有关如何避免这个问题的任何建议,请记住在这种情况下我必须只使用标准库,并且每个Array
构造函数都会占用并最终强制列表。
我希望你发现这个问题和我一样有趣,但希望不要那么莫名其妙。
编辑: user2407038的评论让我意识到我忘了发布分析结果。我试过分析这段代码,而分析器只是说明100%的分配是在handle.l
中执行的,所以看起来只是强制评估列表的任何内容都会占用大量的内存。如上所述,使用force
中的Control.DeepSeq
函数,构造Array
或强制列表的任何其他内容都会导致此行为。我很困惑为什么它需要8 GB的内存来计算包含400 MB数据的列表。即使列表中的每个元素都需要两个64位指针,这仍然只是5的因素,我认为GHC能够做出比这更有效的事情。如果不是这是Array
包的明显瓶颈,因为构造任何数组本身就要求我们分配比数组本身多得多的内存。
所以,最终:有没有人知道为什么强制列表需要如此大量的内存,这在性能上有如此高的成本?
编辑: user2407038提供了指向非常有用的GHC Memory Footprint参考的链接。这完全解释了所有内容的数据大小,几乎完全解释了巨大的开销:指定[Int]
需要5N + 1个字的内存,每个字8个字节,每个元素提供40个字节。在此示例中,建议使用4 GB,占总峰值使用量的一半。很容易相信总和的评估会增加一个类似的因素,所以这回答了我的问题。
感谢所有评论者的帮助。
编辑:正如我上面提到的,我最初遇到了这种行为,为什么要尝试构建Array
。稍微深入了解GHC.Arr
我在构造数组时发现了我认为这种行为的根本原因:构造函数折叠列表以组成ST monad中的程序然后运行它。显然ST在完全组合之前不能被执行,并且在这种情况下,ST结构将是大的并且在输入的大小上是线性的。为了避免这种行为,我们必须以某种方式修改构造函数,以便在ST中添加它们时从列表中流式传输元素。
答案 0 :(得分:1)
这里有多种因素可以发挥作用。第一个是GHC将懒惰地从l
中抬起handle
。这将使handle
能够重用l
,这样您就不必每次都重新计算它,但在这种情况下会造成空间泄漏。如果-ddump-simpl
ified core:
Main.handle_l :: [Int]
[GblId,
Str=DmdType,
Unf=Unf{Src=<vanilla>, TopLvl=True, Value=False, ConLike=False,
WorkFree=False, Expandable=False, Guidance=IF_ARGS [] 40 0}]
Main.handle_l =
case Main.handle3 of _ [Occ=Dead] { GHC.Types.I# y_a1HY ->
GHC.Enum.eftInt 0 y_a1HY
}
计算[0..10^7]
1 的功能隐藏在其他函数中,但基本上是handle_l = [0..10^7]
,位于顶层({ {1}})。它不会被回收,因为您可能会或可能不会再次使用TopLvl=True
。如果我们使用handle
,则handle s = show $ length l
本身将被内联。您找不到任何类型为l
的{{1}}函数。
因此GHC检测到您使用TopLvl=True
两次并创建顶级CAF。 CAF有多大? [Int]
需要两个字:
l
一个用于Int
,一个用于data Int = I# Int#
。 I#
多少钱?
Int#
这是[Int]
的一个词,data [a] = [] | (:) a ([a]) -- pseudo, but similar
的三个词。因此,大小为N的[]
列表的总大小为(3N + 1)+ 2N个单词,在您的情况下为5N + 1个单词。鉴于你的记忆,我假设你的平台上有一个字是8字节,所以我们最终得到了
(:) a ([a])
那么我们如何摆脱这个名单呢?我们的第一个选择是摆脱[Int]
:
5 * 10^8 * 8 bytes = 4 000 000 000 bytes
由于l
规则,现在将在常量内存中运行。虽然我们有handle _ = show $ seq (length [0..10^8]) (sum [0..10^8])
两次,但它们没有相同的名称。如果我们检查foldr/buildr
tats,我们会看到它在恒定的内存中运行:
[0..10^8]
但这并不是很好,因为我们现在必须跟踪-s
的所有用途。如果我们创建一个函数会怎么样?
> SO.exe +RTS -s
5000000050000000 4,800,066,848 bytes allocated in the heap
159,312 bytes copied during GC
43,832 bytes maximum residency (2 sample(s))
20,576 bytes maximum slop
1 MB total memory in use (0 MB lost due to fragmentation)
Tot time (elapsed) Avg pause Max pause
Gen 0 9154 colls, 0 par 0.031s 0.013s 0.0000s 0.0000s
Gen 1 2 colls, 0 par 0.000s 0.000s 0.0001s 0.0002s
INIT time 0.000s ( 0.000s elapsed)
MUT time 4.188s ( 4.232s elapsed)
GC time 0.031s ( 0.013s elapsed)
EXIT time 0.000s ( 0.001s elapsed)
Total time 4.219s ( 4.247s elapsed)
%GC time 0.7% (0.3% elapsed)
Alloc rate 1,146,284,620 bytes per MUT second
Productivity 99.3% of total user, 98.6% of total elapsed
这有效,但我们必须内联[0..10^8]
,否则如果我们使用优化,我们会遇到与以前相同的问题。 handle :: String -> String
handle _ = show $ seq (length $ l ()) (sum $ l ())
where
{-# INLINE l #-}
l _ = [0..10^7] :: [Int]
(以及l
)启用-O1
,它与常见的子表达式消除一起将-O2
提升到顶部。所以我们要么需要内联它,要么使用-ffull-laziness
来阻止这种行为。
1 必须减少列表大小,否则我会开始交换。