绑定 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 能够对此代码执行两个潜在的优化:
- 它可以显式创建惰性列表并开始计算其长度,但确定一旦列表的开头被计算在内,就不再需要这些元素,从而可以立即将它们作为垃圾回收。
- 它可以确定根本不需要创建列表,并通过称为 "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,所以也许有人会看一下它。
我发现了一个关于 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 能够对此代码执行两个潜在的优化:
- 它可以显式创建惰性列表并开始计算其长度,但确定一旦列表的开头被计算在内,就不再需要这些元素,从而可以立即将它们作为垃圾回收。
- 它可以确定根本不需要创建列表,并通过称为 "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,所以也许有人会看一下它。