Haskell能否在列表中评估而不垃圾回收随机索引?

11

据我所知,Haskell只有在某些东西超出其作用域时才进行垃圾回收,因此顶层绑定将仅被评估一次并永远不会超出其作用域。因此,如果我在GHCI中运行此代码,则将评估并保存前50个元素。

let xs = map f [0..]
take 50 xs

我的问题是,当我执行以下代码片段时会发生什么:xs !! 99。垃圾回收器会保存什么?

  1. 保留索引0-49的结果,索引50-98的惰性计算(thunk),索引99的结果,索引100以上的惰性计算。
  2. 保留索引0-49的结果,索引50以上的惰性计算。
  3. 保留索引0-99的结果,索引100以上的惰性计算。

使用索引而不是指数。 - Nikolaj-K
mmap-bytestring 包将允许您 GC 连续数组的中间部分。 - Don Stewart
2个回答

16
Haskell列表是由(:)(“cons”)单元组成并以[](“nil”)值为结尾的链表。 我将像这样绘制这样的单元格。
 [x] -> (tail - remainder of list)
  |
  v
(head - a value)

因此,在考虑评估的内容时,需要考虑两个方面。首先是脊柱,即cons单元的结构,其次是列表包含的。我们可以分别使用2和4,而不是50和99。

ghci> take 2 xs
[0,1]

打印这个列表会强制计算前两个cons单元以及其中的值。因此,您的列表看起来像这样:

[x] -> [x] -> (thunk)
 |      |
 v      v
 0      1

现在,当我们

ghci> xs !! 4
3

我们还没有要求第二或第三个值,但我们需要评估那些 cons 单元以获取第四个元素。因此,我们已经强制将 spine 推到了第四个元素,但我们仅评估了第四个值,因此列表现在是这样的:

[x] -> [x] -> [x] -> [x] -> [x] -> (thunk)
 |      |      |      |      |
 v      v      v      v      v
 0      1   (thunk) (thunk)  4

这张图片中的内容不会被垃圾回收。然而,有时这些惰性求值(thunk)可能占用大量空间或引用大量资源,将它们评估为普通值可以释放这些资源。参见此答案以了解其中的一些细节。


谢谢!我不知道(:)和值是分别评估的。请问您是在哪里学到这个的?有没有关于Haskell GC工作原理的好文献? - Ramith Jayatilleka
1
@RamithJayatilleka 知道这些是分开的,源于理解需要调用,我主要是在学习基本规则后手动简化λ表达式来实现的。GC是这种简化策略的实现细节,负责释放用于表示术语的内存。 - luqui

5

让我们询问性能分析器

我们将编译下面的示例程序,该程序应该大致做到与您的GHCI会话相同。重要的是,像GHCI一样,我们需要print结果,因为这会强制执行计算。

f x = (-x)

xs = map f [0..]

main = do
    print (take 50 xs)
    print (xs !! 99)

我将我的文件保存为example.hs。我们将使用启用性能分析的选项进行编译。

ghc -prof -fprof-auto -rtsopts example.hs

时间轴

我们可以通过时间轴了解函数 f 被应用的次数。

profile +RTS -p

这将生成一个名为example.prof的输出文件,以下是有趣的部分:

COST CENTRE MODULE                     no.     entries
...
   f        Main                        78          51 

我们可以看到 f 被评估了51次,其中50次是为了 print (take 50 xs),一次是为了 print (xs !! 99)。因此,我们可以排除第三种可能性,因为f只被评估了51次,不能有所有索引0-99的结果。
  1. 保留索引0-99的结果,对于100+的索引使用thunk。

堆结果概要

对堆内存进行分析有点棘手。堆分析器默认每隔0.1秒采样一次。我们的程序运行非常快,堆分析器在运行时不会采样。我们将向我们的程序添加旋转,以便堆分析器有机会进行采样。以下代码将旋转一段时间。

import Data.Time.Clock

spin :: Real a => a -> IO ()
spin seconds =
    do
        startTime <- getCurrentTime 
        let endTime = addUTCTime (fromRational (toRational seconds)) startTime
        let go = do
            now <- getCurrentTime
            if now < endTime then go else return ()
        go

我们不希望程序运行时垃圾收集器回收数据,因此在进行旋转后,我们将添加另一个对 xs 的使用。

main = do
    print (take 50 xs)
    print (xs !! 99)
    spin 1
    print (xs !! 0)

我们将使用默认的堆分析选项运行此程序,该选项按成本中心对内存使用情况进行分组。
example +RTS -h

这将生成文件example.hp。我们将从文件中间提取一个样本,其中数字是稳定的(当它在spin中时)。
BEGIN_SAMPLE 0.57
(42)PINNED  32720
(48)Data.Time.Clock.POSIX.CAF   48
(85)spin.endTime/spin/mai...    56
(84)spin.go/spin/main/Mai...    64
(81)xs/Main.CAF 4848
(82)f/xs/Main.CAF   816
(80)main/Main.CAF   160
(64)GHC.IO.Encoding.CAF 72
(68)GHC.IO.Encoding.CodeP...    136
(57)GHC.IO.Handle.FD.CAF    712
(47)Main.CAF    96
END_SAMPLE 0.57

我们可以看到f已经使用了816字节的内存。对于“小”Integer,一个Integer会消耗2个字的内存。在我的系统上,一个字是8个字节的内存,所以一个“小”Integer占用16个字节。因此816/16=51个Integer可能仍然在内存中由f产生。
我们可以通过使用-hd请求通过闭包描述进行分组来检查所有这些内存实际上是否被用于“小”Integer。我们无法同时按闭包描述和成本中心分组内存使用情况,但是我们可以使用-hc将分析限制为单个成本中心,在这种情况下,我们关注f成本中心。
example +RTS -hd -hcf

这句话意思是所有816字节归属于"f",由"small" Integer的构造函数"S#"使用。
BEGIN_SAMPLE 0.57
S#  816
END_SAMPLE 0.57

由于保留了51个整数结果,并且它期望只保留50个整数,因此我们肯定可以删除以下内容

  1. 保留索引0-49的结果,对于索引50+,使用thunk(暂缓计算)

结构和thunks的堆剖面

这样我们就有了一个选择

  1. 保留索引0-49的结果,对于索引50-98,使用thunks,对于索引99,保留结果,对于索引100+,使用thunk

让我们猜测一下这种情况会消耗多少内存。

通常情况下,Haskell的数据类型需要1个word的内存来存储构造函数,每个字段也需要1个word。[]类型的:构造函数有两个字段,因此应该占用3个word的内存,或24字节。然后,100个:将占用2400字节的内存。当我们询问xs的闭包描述时,我们将看到这完全正确。

很难推理thunk的大小,但我们会尝试。对于索引值[50, 98]的值,将有49个thunks。这些thunk中的每一个可能都保存着来自生成器[0..]Integer。它还需要保存thunk的结构,但遗憾的是,在进行性能分析时,thunk的结构会发生变化。此外,还将有一个用于列表剩余部分的thunk。它需要保存生成剩余列表的Integer和thunk的结构。
请求按闭包描述的内存详细信息,以获取xs成本中心。
example +RTS -hd -hcxs

给我们以下示例。
BEGIN_SAMPLE 0.60
<base:GHC.Enum.sat_s34b>    32
<base:GHC.Base.sat_s1be>    32
<main:Main.sat_s1w0>    16
S#  800
<base:GHC.Base.sat_s1bd>    1568
:   2400
END_SAMPLE 0.60

我们的判断是完全正确的,需要2400字节的内存来存储100个“:”符号。有49+1=50个“小型”整数S#占用800字节,其中包括49个未计算值的thunk和列表剩余部分的thunk。还有1568字节,可能是未计算值的49个thunk,每个thunk为32字节或4个字。还有另外80字节,我们不能完全解释,留给列表剩余部分的thunk。
内存和时间的使用情况与我们的信念一致,程序将:
1. 保留索引0-49的结果,保留索引50-98的thunk,保留索引99的结果,保留索引100+的thunk。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接