Haskell 中是否存在固有的 "cost of carry" 垃圾重击?

Is there inherent "cost of carry" of garbage thunks in Haskell?

当 运行 GHC 编译的程序时,我经常看到 GC 花费了大量的周期。

这些数字往往比我的 JVM 经验建议的数字高出一个数量级。特别是,GC 的字节数 "copied" 似乎远远大于我正在计算的数据量。

非严格语言和严格语言之间的这种区别是基本的吗?

不,懒惰本身并不会导致GC中的大量复制。然而,程序员未能正确 管理 懒惰,当然可以这样做。例如,如果持久化数据结构由于惰性修改而最终充满了 thunk 链,那么它最终会变得非常臃肿。

正如 Daniel Wagner 提到的,您可能遇到的另一个主要问题是不变性的成本。虽然在 Haskell 中当然可以使用可变结构进行编程,但尽可能使用不可变结构更为惯用。不可变结构设计有各种权衡。例如,为高性能而设计的那些在持续使用时往往具有较低的分支因子以增加共享,这会在临时使用时导致一些膨胀。

tl;dr: 大多数 JVM 在堆栈帧中执行的操作,GHC 在堆中执行。如果您想将 GHC heap/GC 统计数据与 JVM 等价物进行比较,您确实需要考虑 some JVM 用于推动论点的 bytes/cycles 部分堆栈或在堆栈帧之间复制 return 值。

长版:

针对 JVM 的语言通常使用其调用堆栈。每个被调用的方法都有一个活动堆栈帧,其中包括传递给它的参数的存储空间、额外的局部变量和临时结果,以及用于向它调用的其他方法传递参数和接收结果的 "operand stack" 的空间。

举个简单的例子,如果Haskell代码:

bar :: Int -> Int -> Int
bar a b = a * b
foo :: Int -> Int -> Int -> Int
foo x y z = let u = bar y z in x + u

被编译为 JVM,字节码可能类似于:

public static int bar(int, int);
  Code:
    stack=2, locals=2, args_size=2
       0: iload_0   // push a
       1: iload_1   // push b
       2: imul      // multiply and push result
       3: ireturn   // pop result and return it

public static int foo(int, int, int);
  Code:
    stack=2, locals=4, args_size=3
       0: iload_1   // push y
       1: iload_2   // push z
       2: invokestatic bar   // call bar, pushing result
       5: istore_3  // pop and save to "u"
       6: iload_0   // push x
       7: iload_3   // push u
       8: iadd      // add and push result
       9: ireturn   // pop result and return it

请注意,对imul等内置原语和bar等用户定义方法的调用涉及copying/pushing从本地存储到操作数堆栈的参数值(使用iload 指令),然后调用原语或方法。 Return 值然后需要 saved/popped 到本地存储(使用 istore)或 returned 到调用者 ireturn;有时,可以将 return 值留在堆栈中,用作另一个方法调用的操作数。此外,虽然它在字节码中不是显式的,但 ireturn 指令涉及一个副本,从被调用者的操作数堆栈到调用者的操作数堆栈。当然,在实际的JVM实现中,想必可以通过各种优化来减少复制。

当其他东西最终调用 foo 进行计算时,例如:

some_caller t = foo (1+3) (2+4) t + 1

(未优化的)代码可能如下所示:

       iconst_1
       iconst_3
       iadd      // put 1+3 on the stack
       iconst_2
       iconst_4
       iadd      // put 2+4 on the stack
       iload_0   // put t on the stack
       invokestatic foo
       iconst 1
       iadd
       ireturn

同样,子表达式是通过在操作数堆栈上进行大量推入和弹出来求值的。最终,调用 foo 并将其参数压入堆栈,并将其结果弹出以供进一步处理。

所有这些分配和复制都发生在这个堆栈上,因此本例中不涉及堆分配。

现在,如果使用 GHC 8.6.4 编译相同的代码(为了具体起见,没有优化并且在 x86_64 架构上),会发生什么情况?好吧,生成的程序集的伪代码是这样的:

foo [x, y, z] =
    u = new THUNK(sat_u)                   // thunk, 32 bytes on heap
    jump: (+) x u

sat_u [] =                                 // saturated closure for "bar y z"
    push UPDATE(sat_u)                     // update frame, 16 bytes on stack
    jump: bar y z

bar [a, b] =
    jump: (*) a b

calls/jumps 到 (+)(*) "primitives" 实际上比我想象的要复杂,因为涉及到类型类。例如,跳转到 (+) 看起来更像:

    push CONTINUATION(\f -> f x u)         // continuation, 24 bytes on stack
    jump: (+) dNumInt                      // get the right (+) from typeclass instance

如果你打开 -O2,GHC 会优化掉这个更复杂的调用,但它也会优化掉这个例子中所有其他有趣的东西,所以为了争论,让我们假设上面的伪代码是准确的.

同样,foo 在有人调用它之前没有多大用处。对于上面的 some_caller 示例,调用 foo 的代码部分将类似于:

some_caller [t] =
    ...
    foocall = new THUNK(sat_foocall)       // thunk, 24 bytes on heap
    ...

sat_foocall [] =                           // saturated closure for "foo (1+3) (2+4) t"
    ...
    v = new THUNK(sat_v)                   // thunk "1+3", 16 bytes on heap
    w = new THUNK(sat_w)                   // thunk "2+4", 16 bytes on heap
    push UPDATE(sat_foocall)               // update frame, 16 bytes on stack
    jump: foo sat_v sat_w t

sat_v [] = ...
sat_w [] = ...

请注意,几乎所有这些分配和复制都发生在堆上,而不是堆栈上。

现在,让我们比较一下这两种方法。乍一看,罪魁祸首似乎真的是懒惰的评估。我们正在到处创建这些 thunk,如果评估是严格的,那将是不必要的,对吧?但是,让我们更仔细地看看其中一个 thunk。考虑 foo 定义中 sat_u 的 thunk。它是 32 字节/4 个字,内容如下:

// THUNK(sat_u)
word 0:  ptr to sat_u info table/code
     1:  space for return value
     // variables we closed over:
     2:  ptr to "y"
     3:  ptr to "z"

这个 thunk 的创建与 JVM 代码没有根本的不同:​​

       0: iload_1   // push y
       1: iload_2   // push z
       2: invokestatic bar   // call bar, pushing result
       5: istore_3  // pop and save to "u"

我们没有将 yz 压入操作数栈,而是将它们加载到堆分配的 thunk 中。我们没有将结果从操作数堆栈中弹出到堆栈帧的本地存储中并管理堆栈帧和 return 地址,而是将 space 留给 thunk 中的结果并将一个 16 字节的更新帧推送到在将控制转移到 bar.

之前堆栈

类似地,在 some_caller 中对 foo 的调用中,我们不是通过将常量压入堆栈并调用原语将结果压入堆栈来评估参数子表达式,而是在堆,每个都包含一个指向信息的指针 table / 用于在这些参数上调用原语的代码和 space 用于 return 值;更新框架取代了 JVM 版本中隐含的堆栈簿记和结果复制。

最终,thunk 和更新帧是 GHC 对基于堆栈的参数和结果传递、局部变量和临时工作的替代space。许多发生在 JVM 堆栈帧中的 activity 发生在 GHC 堆中。

现在,JVM 堆栈帧和 GHC 堆中的大部分内容很快就会变成垃圾。主要区别在于,在 JVM 中,当函数 returns 时,在运行时将重要内容复制出来(例如,return 值)后,堆栈帧会自动丢弃。在 GHC 中,堆需要进行垃圾回收。正如其他人所指出的,GHC 运行时是围绕着绝大多数堆对象将立即变成垃圾的想法构建的:快速碰撞分配器用于初始堆对象分配,而不是每次函数都复制出重要的东西returns(对于 JVM),垃圾收集器会在碰撞堆变满时将其复制出来。

显然,上面的玩具示例很荒谬。特别是,当我们开始谈论在 Java 对象和 Haskell ADT 而不是 Ints 上运行的代码时,事情会变得更加复杂。但是,它说明了直接比较 GHC 和 JVM 之间的堆使用情况和 GC 周期并没有多大意义。当然,由于 JVM 和 GHC 方法根本不同,因此似乎不太可能进行准确的核算,并且证明将在现实世界的性能中进行。至少,GHC 堆使用情况和 GC 统计数据的同类比较需要考虑 JVM 在操作数堆栈之间推送、弹出和复制值所花费的周期的某些部分。特别是,至少有一部分 JVM return 指令应该计入 GHC 的 "bytes copied".

至于 "laziness" 对堆使用的贡献(尤其是堆 "garbage"),似乎很难隔离。 Thunk 确实起到了双重作用,既可以替代基于堆栈的操作数传递,又可以作为延迟求值的机制。当然,从懒惰到严格的转变可以减少垃圾——而不是首先创建一个 thunk 然后最终将它评估为另一个闭包(例如,构造函数),你可以直接创建评估的闭包——但这只是意味着而不是您的简单程序在堆上分配了惊人的 172 GB,也许严格版本 "only" 分配了适度的 84 GB。

据我所知,惰性求值对 "bytes copied" 的具体贡献应该很小——如果闭包在 GC 时很重要,则需要复制它。如果它仍然是一个未评估的 thunk,thunk 将被复制。如果它已被评估,则只需要复制最终的闭包。如果有的话,因为复杂结构的 thunk 比它们的评估版本小得多,惰性通常应该 reduce 字节复制。相反,严格的通常大胜利是它允许某些堆对象(或堆栈对象)更快地变成垃圾,所以我们不会以 space 泄漏结束。