此页面位于: https: //gitlab.haskell.org/ghc/ghc/-/wikis/commentary/rts/storage/heap-objects建议 GHC 中的每个对象都有一个标头。
所以可以说我有:
data X = X1 Int# | X2 Int# Int#
Run Code Online (Sandbox Code Playgroud)
我的理解是,X在 的情况下有效地添加到数据类型需要三个单词X1,在 的情况下需要四个单词X2(假设对其进行评估)。X1一个用于指向or 的指针X2(将用它是什么进行标记)、X1or的标头X2,然后是一个或两个单词。
标题的目的是什么?据我了解,Haskell 在运行时擦除所有类型,并且不进行运行时反射(除非选择诸如 之类的东西Typeable)。
如果一个函数是多态的,比如说f :: a -> a,那么除了复制它之外,f实际上不能做任何事情。a它实际上不能检查a,所以如果它需要复制它,它应该只能复制指向它的指针。
现在,如果我们有了g :: Num a => a -> a,那么我们就有了一个类型类字典,它在编译时知道 的布局a。例如,如果我们这样做g (42 :: Int),那么我们知道在编译时创建一个Num Int字典并将其传递给gwith 42 :: Int。该Num Int字典知道 的布局Int,因此,它不需要在运行时检查其标头以获取该信息。
那么构造函数头的目的是什么?它是如何使用的,在什么情况下需要它并在运行时由真实代码实际检查?
编辑
下面的@chi和@William指出,如果一个类型有多个构造函数,那么需要一个标头来在运行时区分它们。这是一个公平的观点,但随之而来的问题是:
大多数关键点已在注释中涵盖,但 GHC 运行时使用构造函数标头来支持惰性求值(无需对共享对象进行重复求值)并支持垃圾收集。
惰性求值是比较难理解的,所以让我们从它开始......
假设我们有:
data X = X1 Int# | X2 Int# Int#
Run Code Online (Sandbox Code Playgroud)
假设我们有一个函数:
foo :: X -> Int
foo (X1 a) = I# a
foo (X2 a b) = I# (a #+ b)
Run Code Online (Sandbox Code Playgroud)
的代码foo需要处理已经评估为 WHNF 的对象的标记指针,但它也需要处理 thunk。GHC 处理 thunk 的方式是将它们放置在内存中:
x_closure:
.quad x_info ; pointer to code to evaluate the thunk
.quad ... ; additional payload, if any, for the code at x_info
Run Code Online (Sandbox Code Playgroud)
并生成foo类似以下内容的代码:
foo(x_ptr):=
if (*x_ptr & 7) == 0 then x_ptr := (*x_ptr)(x_ptr)
if (*x_ptr & 7 == 1) then ...handle X1...
elif (*x_ptr & 7 == 2) then ...handle X2...
Run Code Online (Sandbox Code Playgroud)
在这里,x_ptr := (*x_ptr)(x_ptr)运行 thunk 对象的第一个单词(在上面的示例中)指向的代码x_info,将指针作为输入参数传递给它,并根据返回的结果更新指针。参数和结果约定并不重要,重要的是一个关键事实:为了计算 thunk,GHC 在 thunk 的第一个单词给出的地址处调用代码。
现在,这foo对于完全评估的对象来说效果很好X,无论它们是否使用构造函数头进行布局:
; actual layout used by GHC
x1_closure:
.quad X1_con_info
.quad 42
Run Code Online (Sandbox Code Playgroud)
或没有:
; optimized layout proposed by @Clinton
x1_closure:
.quad 42
Run Code Online (Sandbox Code Playgroud)
前提是所有指针始终被正确标记。
问题是@chi 在评论中已经指出的问题。如果我们有一个对象最初是作为指向 thunk 的未标记指针,当它最终被求值并且该指针被“升级”为标记指针时,可能会有许多原始未标记指针的副本漂浮在各个对象中。为了避免多次评估 thunk,GHC就地评估 thunk ,因此所有那些未标记的指针都指向正确的内存块,但它们仍然是未标记的,因此如果有人调用foo这样的指针,它会尝试跳转到“thunk”的第一个词,如果对象已使用优化布局进行了适当升级,这将是一场灾难:
; optimized layout
x1_closure:
.quad 42
Run Code Online (Sandbox Code Playgroud)
另一方面,如果使用 GHC 布局,一切都会无缝运行:
; actual layout used by GHC
x1_closure:
.quad X1_con_info
.quad 42
Run Code Online (Sandbox Code Playgroud)
与关联的代码块X1_con_info只是X2_con_info暂时将标记添加到未标记指针:
X1_con_info(x_ptr) :=
return(x_ptr+1)
X2_con_info(x_ptr) :=
return(x_ptr+2)
Run Code Online (Sandbox Code Playgroud)
以允许foo成功运行。请注意,这实际上并没有标记此未标记指针的源副本,但垃圾收集器可以稍后执行此操作 - 对于遍历内存中的对象时遇到的每个未标记指针,它可以检查标头指针(例如,X1_con_info)并检查关联的信息块,它将显示该标头指针是否引用构造函数,以及(如果是)其适当的标记。
现在,如果您非常仔细地查看我刚才描述的内容,您将发现可以识别在 WHNF 中开始生命并且从未被未标记指针引用的对象。如果您安排所有指针指向有效负载,并且标头位于有效负载之前的单词,我想您可以优化该对象子集的标头,确保知道在任何地方都没有可能导致的未标记指针试图跳转到不存在的标头地址。
我认为这可行,但在实际的 Haskell 代码中,没有多少对象以 WHNF 开始生命,因此不会节省太多。此外,它还会导致垃圾收集问题......
这让我们想到...呃...垃圾收集。当然“类型被删除”,但就垃圾收集器而言并非如此。它需要在不知道所涉及的类型的情况下遍历对象,识别哪些字段是指针,哪些字段是未装箱的整数。如果每个对象都以标头开头,这很容易:
; actual layout used by GHC
x1_closure:
.quad X1_con_info
.quad 42
Run Code Online (Sandbox Code Playgroud)
因为 GC 可以查阅代码之前的内存块X1_con_info(即实际的信息块)来获取总字段计数以及指针和非指针的标识。
如果没有这些标头,完成垃圾收集的唯一方法就是实际生成具有已擦除类型的自定义垃圾收集器,基本上是垃圾收集“函数”的递归收集,应用程序中的每种类型都有一个函数。