Skip to main content

第 24 章:值的内存表示(Memory Representation of Values)

原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 24。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。

第 23 章介绍的 FFI 接口隐藏了 C 库与 OCaml 运行时之间交换值时的精确细节。这样做有一个简单原因:直接使用这个接口是一项精细操作,需要先理解几个不同的活动部件,才能把事情做对。首先需要知道 OCaml 类型与其运行时内存表示之间的映射;还需要确保代码能正确地与 OCaml 运行时的内存管理交互。

不过,了解 OCaml 内部机制的价值不只限于编写外部函数接口。随着构建和维护更复杂的 OCaml 应用,你会需要与各种操作已编译 OCaml 二进制文件的外部系统工具交互。例如,性能分析工具会根据运行时内存布局报告结果,而调试器执行二进制文件时并不知道静态 OCaml 类型。要有效使用这些工具,就需要在 OCaml 世界与 C 世界之间做一些转换。

好在 OCaml 工具链非常可预测。编译器会尽量减少优化魔法,而是依赖简单直接的执行模型获得良好性能。积累一些经验后,你可以相当精确地知道一段性能关键 OCaml 代码把时间花在哪里。

注:为什么 OCaml 类型会在运行时消失?

OCaml 编译器在编译过程中会经过几个阶段。第一阶段是语法检查,源文件会被解析成抽象语法树(AST)。下一阶段是在 AST 上执行类型检查。在类型合法的程序中,函数不能以意外类型应用。例如,print_endline 函数必须接收单个 string 参数,传入 int 会导致类型错误。

由于 OCaml 会在编译期验证这些性质,运行时就不需要保留那么多信息。因此,编译器后续阶段可以丢弃并简化类型声明,只保留运行时区分多态值真正需要的最小子集。相比 Java 或 .NET 这类方法调用,这带来显著性能收益,因为那些运行时必须查找对象的具体实例并分派方法调用。那些语言会通过 “Just-in-Time” 动态修补摊销部分成本,而 OCaml 更偏向运行时简单性。

第 26 章“编译器前端:解析与类型检查”和第 27 章“编译器后端:字节码与原生代码”会更详细地解释这条编译流水线。

本章会介绍 OCaml 类型到运行时值的精确映射,并通过顶层环境逐步走读。后面第 25 章“理解垃圾回收器”会介绍这些值如何由运行时管理。

24.1 OCaml 块与值(OCaml Blocks and Values)

运行中的 OCaml 程序使用内存块来表示元组、记录、闭包或数组等值。这里的内存块指 RAM 中连续的机器字序列。创建这样的值时,OCaml 程序会隐式分配一个内存块:

# type t = { foo: int; bar: int };;
type t = { foo : int; bar : int; }
# let x = { foo = 13; bar = 14 };;
val x : t = {foo = 13; bar = 14}

类型声明 t 在运行时不占用内存,但随后的 let 绑定会分配一个带两个可用机器字的新内存块。一个机器字保存 foo 字段,另一个保存 bar 字段。OCaml 编译器会把这样的表达式翻译为对 OCaml 运行时系统的显式块分配。

OCaml 使用统一内存表示,每个 OCaml 变量都存储为一个(value)。OCaml 值是单个机器字,它要么是立即整数,要么是指向其他内存的指针。OCaml 运行时会跟踪所有值,以便在不再需要它们时释放。因此,它需要能区分整数值与指针值,因为它会扫描指针来寻找更多值,但不会跟随那些除了立即值以外并不指向有意义内容的整数。

24.1.1 在运行时区分整数与指针(Distinguishing Integers and Pointers at Runtime)

把原始类型(例如整数)包进另一个记录额外元数据的数据结构,称为装箱(boxing)。值被装箱后,垃圾回收器(GC)更容易完成工作,但访问装箱值中的数据时会多一层间接访问。

OCaml 值在运行时并不全都需要装箱。相反,值会使用每个机器字中的一个 tag bit 在运行时区分整数与指针。如果块字的最低位非零,该值就是整数;如果最低位为零,该值就是指针。有几个 OCaml 类型会映射到这种整数表示,包括 boolint、空列表和 unit。有些类型,例如变体,有时使用这种整数表示,有时不使用。特别是对变体来说,常量构造器,也就是 None 这类没有参数的构造器,会表示为整数;而 Some 这类携带关联值的构造器则会被装箱。

这种表示意味着整数在 OCaml 中是未装箱的运行时值,因此可以直接存储,不必分配包装块。它们可以直接通过寄存器传给其他函数调用,通常是 OCaml 中最便宜、最快的值。

如果最低位为零,值就会被视为内存指针。指针值仍然可以原样存储,因为指针保证按机器字对齐,其底部位总是零。

这种内存表示剩下的唯一问题,是如何区分指向 OCaml 值的指针(GC 应该跟随)和指向系统堆中 C 值的指针(GC 不应跟随)。

机制很简单,因为运行时系统会跟踪它为 OCaml 值分配的堆块。如果指针位于标记为由 OCaml 运行时管理的堆块内部,就假定它指向 OCaml 值。如果它指向 OCaml 运行时区域之外,就会被视为指向其他系统资源的不透明 C 指针。

注:关于 OCaml 机器字对齐指针的一点历史

敏锐的读者可能会好奇,OCaml 如何保证所有指针都按机器字对齐。早年 Sparc、MIPS 和 Alpha 这类 RISC 芯片很常见时,指令集架构禁止未对齐内存访问,否则会导致终止程序的 CPU 异常。因此,所有指针在历史上都会被舍入到架构机器字大小,通常是 32 位或 64 位。

现代 CISC 处理器,例如 Intel x86,确实支持未对齐内存访问,但访问按机器字对齐时芯片仍然运行得更快。因此,OCaml 直接规定所有指针必须按机器字对齐,从而保证任何有效指针的底部几位都是零。把最低位设置为非零值是一种简单的整数标记方式,代价是损失一位精度。

更敏锐的读者可能还会关心这种带标记表示对整数算术的性能影响。由于最低位被设置,对整数做任何操作都必须右移最低位以恢复“原生”值。在这种情况下,原生代码 OCaml 编译器会生成高效的 x86 汇编代码,利用现代处理器指令尽可能隐藏额外移位。加法是一条 LEA x86 指令,减法可以是两条指令,乘法也只多几条。

24.2 块和值(Blocks and Values)

OCaml (block)是堆上的基本分配单位。一个块由一个机器字的头部组成,头部大小取决于 CPU 架构,是 32 位或 64 位;其后跟随可变长度数据,数据要么是不透明字节,要么是一组字段。头部有一个多用途 tag 字节,用于定义后续数据应解释为不透明字节还是 OCaml 字段。

GC 从不检查不透明字节。如果 tag 表示存在一组 OCaml 字段,那么这些字段内容都会被视为更多合法 OCaml 值。GC 总是检查字段,并在收集过程中跟随它们。

块的内存表示

size 字段以机器字为单位记录块长度。在 32 位平台上,该字段为 22 位,这就是 OCaml 字符串在该架构上限制为 16 MB 的原因。如果需要更大字符串,可以切换到 64 位主机,或者使用 Bigarray 模块。

2 位的 color 字段供 GC 在标记-清扫收集期间跟踪状态。第 25 章“理解垃圾回收器”会回到这个字段。不管怎样,OCaml 源代码不会暴露这个 tag。

块的 tag 字节是多用途的,它指示数据数组表示不透明字节还是字段。如果块的 tag 大于或等于 No_scan_tag(251),那么块的数据全是不透明字节,不会被收集器扫描。最常见的这类块是 string 类型,本章稍后会更详细地描述它。

块中值的精确表示取决于其静态 OCaml 类型。所有 OCaml 类型都会被提炼为 values,总结如下:

  • intchar 会直接存储为一个值,左移 1 位,并把最低有效位设置为 1。
  • unit[]false 都存储为 OCaml int 0。
  • true 存储为 OCaml int 1。
  • Foo | Bar 这类变体从 0 开始,按升序存储为 OCaml int
  • 带参数的 Foo | Bar of int 变体会被装箱;无参数变体不装箱。
  • 与普通变体相比,带参数的多态变体会被装箱,并额外用一个头部字存储值。无参数多态变体不装箱。
  • 浮点数存储为一个带单字段的块,该字段包含双精度浮点值。
  • 字符串是按机器字对齐的字节数组,并带显式长度。
  • [1; 2; 3] 列表存储为 1::2::3::[],其中 [] 是整数,而 h::t 是 tag 为 0 且带两个参数的块。
  • 元组、记录和数组都存储为值组成的 C 数组。数组大小可以变化,但元组和记录大小固定。
  • 全是 float 的记录或数组会使用一个特殊 tag,表示未装箱 float 数组,或只包含 float 字段的记录。

24.2.1 整数、字符和其他基础类型(Integers, Characters, and Other Basic Types)

许多基础类型在运行时会高效地存储为未装箱整数。原生 int 类型是最明显的例子,尽管因为 tag bit 会损失一位精度。unit 和空列表 [] 这类其他原子类型会存储为常量整数。布尔值中,truefalse 分别对应 10

空列表和 unit 这类基础类型使用起来非常高效,因为整数从不在堆上分配。只要函数参数不是太多,它们就可以直接通过寄存器传递,甚至不出现在栈上。x86_64 这类现代架构有大量空闲寄存器,可以进一步提升未装箱整数的使用效率。

24.3 元组、记录和数组(Tuples, Records, and Arrays)

元组布局

元组、记录和数组在运行时都以相同方式表示为 tag 为 0 的块。元组和记录的大小由编译期确定,是常量;数组则可以是可变长度。虽然在 OCaml 类型系统中,数组被限制为只能包含一种元素类型,但内存表示并不要求这一点。

可以自己使用 Obj 模块检查块和直接整数之间的差异。Obj 模块会把值的内部表示暴露给 OCaml 代码:

# Obj.is_block (Obj.repr (1,2,3));;
- : bool = true
# Obj.is_block (Obj.repr 1);;
- : bool = false

Obj.repr 函数取得任意 OCaml 值的运行时表示。Obj.is_block 检查最低位,以判断该值是块头还是未装箱整数。

24.3.1 浮点数和数组(Floating-Point Numbers and Arrays)

OCaml 中的浮点数总是存储为完整的双精度值。单个浮点值存储为一个带单字段的块,字段中包含该数字。这个块设置了 Double_tag,它向收集器表示该浮点值不需要被扫描:

# Obj.tag (Obj.repr 1.0);;
- : int = 253
# Obj.double_tag;;
- : int = 253

由于每个浮点值都装箱在单独的内存块中,与未装箱整数相比,处理大型 float 数组可能很低效。因此,OCaml 会对只包含 float 类型的记录或数组做特殊处理。它们存储在一个块中,float 会直接打包在数据区,并设置 Double_array_tag,向收集器表示其内容不是 OCaml 值。

浮点数组布局

首先,检查 float 数组是否确实与普通浮点值有不同的 tag 编号:

# Obj.double_tag;;
- : int = 253
# Obj.double_array_tag;;
- : int = 254

这说明 float 数组的 tag 值是 254。现在用 Obj.tag 函数测试一些示例值,检查分配出的块是否具有预期运行时 tag;同时使用 Obj.double_field 从块中取得一个 float:

# Obj.tag (Obj.repr [| 1.0; 2.0; 3.0 |]);;
- : int = 254
# Obj.tag (Obj.repr (1.0, 2.0, 3.0) );;
- : int = 0
# Obj.double_field (Obj.repr [| 1.1; 2.2; 3.3 |]) 1;;
- : float = 2.2
# Obj.double_field (Obj.repr 1.234) 0;;
- : float = 1.234

首先测试的是 float 数组确实有正确的未装箱 float 数组 tag 值(254)。不过,下一行测试的是浮点值组成的元组,它不会以同样方式优化,而是具有普通元组 tag 值(0)。

只有记录和数组能拥有 float 数组优化;对记录来说,每个字段都必须是 float。

24.4 变体和列表(Variants and Lists)

如果基础变体类型的任意分支都没有额外参数,它们会直接存储为 OCaml 整数,从第一个选项的 0 开始按升序排列:

# type t = Apple | Orange | Pear;;
type t = Apple | Orange | Pear
# ((Obj.magic (Obj.repr Apple)) : int);;
- : int = 0
# ((Obj.magic (Obj.repr Pear)) : int);;
- : int = 2
# Obj.is_block (Obj.repr Apple);;
- : bool = false

Obj.magic 会不安全地强制在任意两个 OCaml 类型之间做类型转换。在这个例子中,int 类型提示会取得运行时整数值。Obj.is_block 确认该值不是更复杂的块,而只是一个 OCaml int

带参数的变体稍微复杂一些。它们存储为块,值的 tag 从 0 开始递增,计数时只数从左到右带参数的变体。参数会作为机器字存储在块中:

# type t = Apple | Orange of int | Pear of string | Kiwi;;
type t = Apple | Orange of int | Pear of string | Kiwi
# Obj.is_block (Obj.repr (Orange 1234));;
- : bool = true
# Obj.tag (Obj.repr (Orange 1234));;
- : int = 0
# Obj.tag (Obj.repr (Pear "xyz"));;
- : int = 1
# (Obj.magic (Obj.field (Obj.repr (Orange 1234)) 0) : int);;
- : int = 1234
# (Obj.magic (Obj.field (Obj.repr (Pear "xyz")) 0) : string);;
- : string = "xyz"

在前面的例子中,AppleKiwi 值仍然存储为普通 OCaml 整数,值分别是 01OrangePear 都有参数,并存储为 tag 从 0 开始递增的块,因此 Pear 的 tag 是 1,这也由 Obj.tag 验证。最后,参数是块中包含 OCaml 值的字段,可以使用 Obj.field 取得它们。

列表的表示与把列表写作带 NilCons 的变体类型时完全一样。空列表 [] 是整数 0;后续块 tag 为 0,并带两个参数:一个保存当前值的块,以及指向列表剩余部分的指针。

警告:Obj 模块有害

Obj 是一个未文档化模块,会暴露 OCaml 编译器和运行时的内部机制。它对检查和理解代码在运行时的行为非常有用,但除非理解后果,否则绝不应在生产代码中使用。该模块绕过 OCaml 类型系统,可能导致内存损坏和段错误。

Coq 这类定理证明器确实会输出内部使用 Obj 的代码,但外部模块签名从不暴露它。除非你也有一份机器检查的正确性证明来配合对 Obj 的使用,否则除了调试以外,请远离它。

由于这种编码,每个类型定义中带参数变体的数量限制大约是 240 个;但无参数变体的数量只受原生整数大小限制,也就是 31 位或 63 位。这个限制源于 tag 字节的大小,以及部分高编号 tag 被保留这一事实。

24.5 多态变体(Polymorphic Variants)

编写代码时,多态变体比普通变体更灵活,但运行时效率略低。这是因为可用于优化其内存布局的静态编译期信息较少。

没有任何参数的多态变体会存储为未装箱整数,因此和普通变体一样,只占用一个机器字内存。这个整数值由对变体名称应用哈希函数决定。这个哈希函数通过 compiler-libs 包暴露出来,该包揭示了 OCaml 编译器的一些内部细节:

# #require "ocaml-compiler-libs.common";;
# Btype.hash_variant "Foo";;
- : int = 3505894
# (Obj.magic (Obj.repr `Foo) : int);;
- : int = 3505894

该哈希函数被设计为在 32 位和 64 位架构上给出相同结果,因此内存表示在不同 CPU 和宿主类型之间是稳定的。

当数据类型构造器包含参数时,多态变体比普通变体使用更多内存。普通变体使用 tag 字节编码变体值,并把字段留给内容,但这个单字节不足以编码多态变体的哈希值。因此,它们必须分配一个新块(tag 为 0)并把值存放在那里。于是,带构造器的多态变体会比普通变体构造器多用一个机器字。

当多态变体构造器有多个参数时,相比普通变体还会有另一处低效。普通变体把参数保存为一个包含多个字段的单一扁平块;但多态变体必须采用更灵活的统一内存表示,因为它们可能在不同编译单元的不同上下文中复用。它们会为参数分配一个元组块,并从变体的参数字段指向该元组。因此,这类变体会额外使用三个机器字,并因元组而多一次内存间接访问。

在典型应用中,额外空间使用通常并不重要,而且多态变体比普通变体灵活得多。不过,如果编写的是要求高性能或必须在严格内存限制内运行的代码,其运行时布局至少非常可预测。OCaml 编译器不会因为优化 pass 而切换内存表示。这让你可以参考这些准则和源代码,预测精确运行时布局。

24.6 字符串值(String Values)

OCaml 的 string(以及可变近亲 bytes)是标准 OCaml 块,头部大小以机器字为单位定义字符串大小。String_tag(252)高于 No_scan_tag,表示块内容对收集器是不透明的。块内容就是字符串内容,并带有用于把块对齐到机器字边界的填充字节。

字符串块布局

在 32 位机器上,填充会根据字符串长度和机器字大小的模计算,以确保结果按机器字对齐。64 位机器会把可能的填充扩展到最多 7 字节,而不是 3 字节。给定字符串长度对 4 取模:

  • 0 的填充为 00 00 00 03
  • 1 的填充为 00 00 02
  • 2 的填充为 00 01
  • 3 的填充为 00

这种字符串表示非常巧妙:它能确保内容总是由填充字终止为零,同时仍然可以高效计算长度,而不必扫描整个字符串。使用的公式如下:

number_of_words_in_block * sizeof(word) - last_byte_of_block - 1

把字符串传给 C 时,保证的 NULL 终止很方便,但 OCaml 代码不会依赖它来计算长度。因此,OCaml 字符串可以在字符串中的任意位置包含 NULL 字节。

需要注意,任何接收这些缓冲区的 C 库函数,也必须能够处理缓冲区内容中的任意字节,而不能期待 C 字符串。例如,C 标准库函数 memcopymemmove 可以操作任意数据,但 strlenstrcpy 都要求 NULL 终止缓冲区,并且没有机制在内容中编码 NULL 值。

24.7 自定义堆块(Custom Heap Blocks)

OCaml 通过 Custom_tag 支持自定义堆块,让运行时可以对 OCaml 值执行用户定义操作。自定义块像普通块一样位于 OCaml 堆中,大小可以由用户决定。Custom_tag(255)高于 No_scan_tag,因此不会被 GC 扫描。

自定义块中数据的第一个机器字,是指向一个自定义操作 struct 的 C 指针。自定义块不能有指向 OCaml 块的指针,对 GC 来说是不透明的:

struct custom_operations {
char *identifier;
void (*finalize)(value v);
int (*compare)(value v1, value v2);
intnat (*hash)(value v);
void (*serialize)(value v,
/*out*/ uintnat * wsize_32 /*size in bytes*/,
/*out*/ uintnat * wsize_64 /*size in bytes*/);
uintnat (*deserialize)(void * dst);
int (*compare_ext)(value v1, value v2);
};

这些自定义操作指定运行时应如何执行多态比较、哈希和二进制 marshaling。它们还可以选择包含一个 finalizer,运行时会在块即将被垃圾回收前调用它。这个 finalizer 与普通 OCaml finalizer 无关,后者通过 Gc.finalize 创建并会在第 25 章“理解垃圾回收器”中解释。这里的 finalizer 用于调用 free 这类 C 清理函数。

24.7.1 使用 Bigarray 管理外部内存(Managing External Memory with Bigarray)

自定义块的一个常见用途,是直接从 OCaml 中管理外部系统内存。Bigarray 接口最初用于与 Fortran 代码交换数据,它会把一块系统内存映射为 OCaml 可访问的多维数组。Bigarray 操作直接作用于外部内存,不需要把它复制到 OCaml 堆中;对于大型数组来说,复制可能是昂贵操作。

Bigarray 的用途远不止科学计算。几个 Core 库会把它用于通用 I/O:

Iobuf : Iobuf 模块把 I/O 缓冲区映射为一维字节数组。它提供滑动窗口接口,让消费者进程可以在生产者填充缓冲区的同时从缓冲区读取。这使 OCaml 可以使用由操作系统在外部分配的 I/O 缓冲区,而不需要额外数据复制。

Bigstring : Bigstring 模块提供类似 String 的接口,但内部使用 BigarrayBigbuffer 会把这些收集成可扩展字符串缓冲区,并能完全在外部系统内存上操作。

Lacaml 库不是 Core 的一部分,但它为广泛使用的 BLAS 和 LAPACK 数学 Fortran 库提供了推荐接口。这些库允许开发者为需要线性代数的应用编写高性能数值代码。它支持大型向量和矩阵,同时具备 OCaml 的静态类型安全性,使编写安全算法更容易。