在运行GHC编译的程序时,我经常看到GC中花费的大量周期。
这些数字往往比我的JVM经验所表明的要高出几个数量级。特别是,GC "复制"的字节数似乎比我正在计算的数据量大得多。
非语言和严格语言之间的这种区别是根本性的吗?
tl;dr:JVM在堆栈帧中所做的大部分工作,GHC在堆上做。 如果你想将 GHC 堆/GC 统计信息与 JVM 等效值进行比较,您确实需要考虑 JVM 在堆栈上推送参数或在堆栈帧之间复制返回值所花费的字节/周期的某些部分。
长版本:
面向 JVM 的语言通常使用其调用堆栈。 每个调用的方法都有一个活动堆栈帧,其中包括传递给它的参数、其他局部变量和临时结果的存储,以及用于将参数传递给它调用的其他方法并从中接收结果的"操作数堆栈"的空间。
举个简单的例子,如果 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
)的调用涉及将参数值从本地存储复制/推送到操作数堆栈(使用iload
指令),然后调用基元或方法。 然后需要将返回值保存/弹出到本地存储(使用istore
)或使用ireturn
返回给调用方;有时,可以在堆栈上保留返回值,以用作另一个方法调用的操作数。 此外,虽然在字节代码中没有显式,但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
由于涉及的类型类,对(+)
和(*)
"原语"的调用/跳转实际上比我想象的要复杂得多。 例如,跳转到(+)
看起来更像:
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 [] = ...
请注意,几乎所有这些分配和复制都发生在堆上,而不是堆栈上。
现在,让我们比较一下这两种方法。 乍一看,罪魁祸首真的是懒惰的评价。 我们到处制造这些笨蛋,如果评估严格,就没有必要了,对吧? 但是,让我们更仔细地看一下其中一个笨蛋。 考虑foo
定义中对sat_u
的混乱。 它是 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"
我们没有将y
和z
推送到操作数堆栈上,而是将它们加载到堆分配的 thunk 中。 我们没有将结果从操作数堆栈弹出到堆栈帧的本地存储中并管理堆栈帧和返回地址,而是在 thunk 中为结果留出空间,并在将控制权转移到bar
之前将 16 字节的更新帧推送到堆栈上。
类似地,在some_caller
中对foo
的调用中,我们不是通过在堆栈上推送常量并调用原语来在堆栈上推送结果来计算参数子表达式,而是在堆上创建了 thunks,每个 thunks 都包含一个指向 info 表/代码的指针,用于在这些参数上调用原语和返回值的空间; 更新帧取代了 JVM 版本中隐含的堆栈簿记和结果复制。
最终,thunks 和更新帧是 GHC 对基于堆栈的参数和结果传递、局部变量和临时工作区的替代。 在 JVM 堆栈帧中发生的许多活动都发生在 GHC 堆中。
现在,JVM堆栈帧和GHC堆中的大多数东西很快就会变成垃圾。 主要区别在于,在 JVM 中,在运行时复制了重要内容(例如,返回值)之后,当函数返回时,堆栈帧会自动抛出。 在 GHC 中,堆需要垃圾回收。 正如其他人所指出的,GHC 运行时是围绕绝大多数堆对象将立即成为垃圾的想法构建的:快速凸起分配器用于初始堆对象分配,而不是每次函数返回时都复制重要内容(如 JVM),垃圾收集器在凹凸堆变满时将其复制出来。
显然,上面的玩具例子是荒谬的。 特别是,当我们开始谈论在 Java 对象和 Haskell ADT 上运行的代码时,事情会变得更加复杂,而不是Ints
. 但是,它有助于说明一点,即直接比较GHC和JVM之间的堆使用情况和GC周期并没有多大意义。当然,精确的会计似乎是不可能的,因为JVM和GHC方法有太大的根本不同,而证据将在现实世界的性能中。 至少,GHC 堆使用情况和 GC 统计信息的同类比较需要考虑 JVM 在操作数堆栈之间推送、弹出和复制值所花费的周期的某些部分。 特别是,至少有一部分JVMreturn
指令应该计入GHC的"复制字节数"。
至于"懒惰"对堆使用(特别是堆"垃圾")的贡献,似乎很难隔离。 Thunks 实际上扮演着双重角色,既可以替代基于堆栈的操作数传递,也可以作为延迟评估的机制。 当然,从懒惰到严格的转换可以减少垃圾 - 而不是首先创建一个thunk,然后最终评估它到另一个闭包(例如,构造函数),你可以直接创建评估的闭包 - 但这只是意味着,而不是你的简单程序在堆上分配一个令人兴奋的172 GB,也许严格版本"仅"分配适度的84 GB。
据我所知,惰性求值对"复制的字节数"的具体贡献应该是最小的——如果闭包在 GC 时很重要,则需要复制它。 如果它仍然是一个未评估的 thunk,则该 thunk 将被复制。 如果已对其进行评估,则只需复制最终闭包。 如果有的话,由于复杂结构的 thunks 比它们的评估版本小得多,懒惰通常应该减少复制的字节数。 相反,严格性通常的最大优势是它允许某些堆对象(或堆栈对象)更快地成为垃圾,这样我们就不会最终出现空间泄漏。
不,懒惰本身并不会导致 GC 中的大量复制。然而,程序员未能正确管理懒惰当然可以做到这一点。例如,如果一个持久的数据结构由于懒惰的修改而最终充满了一连串的混乱,那么它最终会变得非常臃肿。
正如丹尼尔·瓦格纳(Daniel Wagner)所提到的,你可能遇到的另一个主要问题是不变性的成本。虽然在 Haskell 中使用可变结构进行编程当然是可能的,但在可能的情况下使用不可变的结构更为习惯。不可变的结构设计具有各种权衡。例如,为高性能而设计的那些在持续使用时往往具有低分支因子来增加共享,这会导致它们在短暂使用时出现一些膨胀。