Haskell是否有内在的“垃圾成本”?

时间:2019-04-07 19:18:51

标签: haskell garbage-collection ghc language-design

运行GHC编译的程序时,我经常会在GC中花费大量的时间。

这些数字往往比我的JVM经验所建议的高几个数量级。特别是,GC“复制”的字节数似乎比我正在计算的数据量大得多。

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

2 个答案:

答案 0 :(得分:8)

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会优化此更复杂的调用,但也会优化此示例中有趣的所有其他事情,因此,为了争辩,让我们假设上面的伪代码是准确的。 / p>

同样,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 [] = ...

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

现在,让我们比较一下这两种方法。乍一看,罪魁祸首确实是懒惰的评价。我们在各地创建这些代码,如果评估很严格,那就没必要了,对吧?但是,让我们更仔细地查看这些重击之一。在sat_u的定义中考虑foo的重击。它是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"

此文件的创建与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中。与其将结果从操作数堆栈弹出到我们的堆栈帧的本地存储器中,而不是管理堆栈帧和返回地址,我们将结果留在thunk中,并在将控制权转移到{{1 }}。

类似地,在bar中对foo的调用中,我们不是通过在堆栈上压入常量并调用基元在堆栈上压入结果来评估参数子表达式,而是在堆上创建了thunk,每一个都包含一个指向信息表/代码的指针,该信息表/代码用于调用这些参数上的原语,并为返回值提供空间;更新框架取代了JVM版本中隐含的堆栈簿记和结果复制。

最终,重排和更新帧是GHC替代基于堆栈的参数和结果传递,局部变量和临时工作区的。 JVM堆栈框架中发生的许多活动都发生在GHC堆中。

现在,JVM堆栈框架和GHC堆中的大多数内容很快变成垃圾。主要区别在于,在JVM中,运行时将重要内容(例如,返回值)复制出来后,函数返回时会自动抛出堆栈帧。在GHC中,需要对堆进行垃圾收集。正如其他人指出的那样,GHC运行时基于以下想法:绝大多数堆对象将立即变为垃圾:快速缓冲分配器用于初始堆对象分配,而不是每次函数返回时都复制重要的东西(对于JVM),垃圾收集器会在凹凸堆变满时将其复制出来。

显然,上面的玩具示例是荒谬的。特别是,当我们开始谈论在Java对象和Haskell ADT而不是some_caller上运行的代码时,事情将变得更加复杂。但是,它只是用来说明直接比较GHC和JVM之间的堆使用情况和GC周期的意义不大。当然,由于JVM和GHC方法在根本上太不同了,因此实际上似乎无法进行精确的核算,并且证明将是真实的性能。至少,要逐一比较GHC堆使用情况和GC统计信息,这需要考虑JVM在操作数堆栈之间推入,弹出和复制值所花费的部分周期。特别是,JVM Ints指令的至少一部分应计入GHC的“已复制字节”。

关于“惰性”对堆使用(尤其是堆“垃圾”)的贡献,似乎很难隔离。 Thunk确实扮演着双重角色,以替代基于堆栈的操作数传递和延迟评估机制。从懒惰到严格的转变当然可以减少垃圾-可以先创建评估后的闭包,而不是先创建一个thunk然后再对其进行评估,然后再将其评估到另一个闭包(例如,构造函数),但这仅意味着您的简单程序在堆上分配了惊人的172 GB,也许严格的版本“ only”分配了适度的84 GB。

据我所知,惰性求值对“复制的字节”的特定贡献应该是最小的-如果闭包在GC时很重要,则需要将其复制。如果仍然是未经评估的重击,将复制该重击。如果已经过评估,则只需要复制最后的闭包。如果有的话,由于复杂结构的重击比其评估版本小得多,因此懒惰通常应 reduce 个字节复制。取而代之的是,严格来说通常是要赢得胜利的是,它可以使某些堆对象(或堆栈对象)更快地变为垃圾,因此我们最终不会出现空间泄漏。

答案 1 :(得分:3)

不,懒惰并不是天生就导致GC中大量复制。程序员未能正确地管理懒惰当然可以做到这一点。例如,如果一个持久性数据结构由于延迟修改而最终被一堆乱七八糟的东西封住,那么它将最终变得bad肿。

正如丹尼尔·瓦格纳(Daniel Wagner)所提到的,您可能遇到的另一个主要问题是不变性的代价。虽然当然可以在Haskell中使用可变结构进行编程,但在可能的情况下使用不可变的结构要更加习惯。不变的结构设计需要权衡取舍。例如,长期使用为高性能而设计的服务器趋向于具有较低的分支因子以增加共享,这在短暂使用时会导致一些膨胀。