Haskell 是否有“内在的”;“运输成本”;哈斯克尔的垃圾桶?

Haskell 是否有“内在的”;“运输成本”;哈斯克尔的垃圾桶?,haskell,garbage-collection,ghc,language-design,Haskell,Garbage Collection,Ghc,Language Design,在运行GHC编译程序时,我经常看到GC中花费了大量的周期 这些数字往往比我的JVM经验所表明的要高出一个数量级。特别是,GC“复制”的字节数似乎远远大于我正在计算的数据量 非严格语言和严格语言之间的这种差异是基本的吗?不,懒惰本质上不会导致GC中的大量复制。然而,程序员不能正确地管理懒惰,这当然可以做到。例如,如果一个持久数据结构由于延迟修改而充满了thunk链,那么它最终将严重膨胀 正如Daniel Wagner提到的,您可能会遇到的另一个主要问题是不变性的成本。当然,在Haskell中使用可

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

这些数字往往比我的JVM经验所表明的要高出一个数量级。特别是,GC“复制”的字节数似乎远远大于我正在计算的数据量


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

不,懒惰本质上不会导致GC中的大量复制。然而,程序员不能正确地管理懒惰,这当然可以做到。例如,如果一个持久数据结构由于延迟修改而充满了thunk链,那么它最终将严重膨胀


正如Daniel Wagner提到的,您可能会遇到的另一个主要问题是不变性的成本。当然,在Haskell中使用可变结构编程是可能的,但在可能的情况下使用不可变结构则更为惯用。不变的结构设计有各种权衡。例如,长期使用时为高性能而设计的产品往往具有较低的分支因子以增加共享,这会导致短暂使用时出现一些膨胀。

tl;dr:JVM在堆栈帧中执行的大部分操作,GHC在堆上执行。如果您想将GHC heap/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
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
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 [] = ...
请注意,调用内置原语(如
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体系结构上编译),会发生什么?生成的程序集的伪代码类似于:

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
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
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 [] = ...
调用/跳转到
(+)
(*)
的“原语”实际上比我所说的要复杂得多,因为所涉及的类型类。例如,跳转到
(+)
看起来更像:

    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
的代码部分如下所示:

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
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
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的定义中考虑<代码> SATuu u/CODE的thuk。它是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
的调用中,我们在堆上创建了thunk,而不是通过在堆栈上推送常量和调用原语在堆栈上推送结果来计算参数子表达式,每个参数都包含一个指向info表/代码的指针,用于调用这些参数的原语,并为返回值留出空间;更新框架取代了JVM版本中隐含的堆栈簿记和结果复制

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

<