绑定 len = length xs 然后计算 len 会导致 GHC 消耗大量 RAM

Binding `len = length xs` and then calculating `len` causes GHC to consume lots of RAM

我发现了一个关于 GHCi 和列表的奇怪之处。

执行此命令需要一些时间,只是 returns 正确答案。

ghci> length [1..10^8]
100000000

但是,将其绑定到一个变量并执行会导致 GHC 消耗大约 5 GiB 的 RAM 而不会释放,直到 GHCi 会话结束。在实际退出之前多消耗 3 GiB 后键入 :quit

ghci> len = length [1..10^8]
ghci> len
-- Consumes 5 GiB
100000000
ghci> :quit
-- Consumes 3 GiB
-- Exits

这正常吗?这些命令有什么区别?

GHC 版本为 8.2.2。

更新: -O0 执行的优化与我最初理解的略有不同。此外,添加了关于提交新 Trac 错误的注释。

我可以在 GHC 8.2.2 中重现它。直接计算表达式(或使用 let 将其绑定到变量然后对其进行计算)都可以快速完成:

Prelude> length [1..10^8]
10000000    -- pretty fast
Prelude> let len = length [1..10^8]
Prelude> len
10000000    -- pretty fast
Prelude>

但是,使用 let-free 语法:

Prelude> len = length [1..10^8]
Prelude> len
10000000
Prelude>

需要更长的时间并分配大量直到会话结束才释放的内存。

请注意,这是特定于 GHCi 和交互模式的——在真实的编译 Haskell 程序中,不会有任何问题。编译以下内容将 运行 快速且不会消耗过多内存:

len = length [1..10^8]
main = print len

要了解发生了什么,您应该了解 Haskell 能够对此代码执行两个潜在的优化:

  1. 它可以显式创建惰性列表并开始计算其长度,但确定一旦列表的开头被计算在内,就不再需要这些元素,从而可以立即将它们作为垃圾回收。
  2. 它可以确定根本不需要创建列表,并通过称为 "list fusion" 的过程创建编译代码,直接从 1 计数到 10^8,而无需尝试将这些数字放入任何类型的数据结构。

当使用优化(-O1-O2)编译此代码时,GHC 将执行优化#2。编译后的版本将 运行 快速存储在少量常量内存中(在 运行 时间内驻留几兆字节)。如果你 运行 这与:

$ time ./Length +RTS -s

为了收集统计数据,您会发现 GHC 仍在分配大约 1.6 GB 的堆,但这实际上是为了在递增时存储单个 Integer 值。 (由于 Haskell 中的值是不可变的,因此必须为每个增量分配一个新的 Integer。)如果强制类型为 Int:

len = length [(1::Int)..10^8]

然后程序将只分配几千字节的堆,您可以看到确实没有分配列表。

事实证明,当这段代码在没有优化的情况下编译时 (-O0),GHC 只执行优化 #1(正如@Carl 所指出的),但它确实做得很好,以至于即使 GHC 统计数据显示大量堆分配,程序 仍然 运行 运行速度非常快,内存占用非常小。

然而,当此代码在 GHCi 中编译为字节码时,不仅使用了优化 #1,而且 GHC 在垃圾收集列表方面做得并不好。生成了一个数 GB 的巨大列表,开始时垃圾收集器几乎 与其生成的速度一样快。内存使用最终相当大,但至少它是相对稳定的。

您可以通过打开 timing/memory 统计数据来查看:

> :set +s
> length [1..10^8]
100000000
(1.54 secs, 7,200,156,128 bytes)
>

这意味着这段代码实际上分配了一个7.2GB的列表;幸运的是,它几乎可以像生成一样快地被丢弃,因此 GHCi 进程在此计算后使用的内存仍然相当适中。

你会看到:

> let len = length [1..10^8]
> len

和:

> len = length [1..10^8]
> len

咀嚼完全相同的大量内存(大约 7.2 GB)。

不同之处在于,出于某种原因,let 版本允许在计数时对列表进行垃圾回收,而非 let 版本则不允许。

最后,这几乎可以肯定是 GHCi 错误。它可能与已报告的现有 space 泄漏错误之一有关(例如,Trac #12848 or #14336), or maybe it's a new one. I decided to file it as #14789,所以也许有人会看一下它。