30 秒看懂
Jane Street 编程语言研究者 Stephen 解释 OCaml 的统一值表示为什么会对某些数据装箱,以及布局注解如何允许高效的非装箱值。他展示抽象类型、立即数、记录和一种可能的可空类型,然后与观众讨论设计权衡。 这场 2019 年演讲介绍的是进行中的工作:原型检查布局注解,但尚未编译出拟议的非装箱表示,几个示例也仍属设想。
- 1数据小可能更快不同缓存层的延迟差异很大,因此紧凑表示可以让更多数据留在靠近处理器的位置,提升性能。
- 2统一表示与特化统一值简化泛型编译,但不适合该表示的值需要装箱;按类型特化则增加代码大小和编译时间。
- 3抽象类型的布局布局注解可以表明抽象标识符占用原始 64 位数据,而不暴露其语义定义。
- 4非装箱记录拟议的记录布局允许内联传递字段和嵌套记录,而不是通过单独的盒子。
- 5选项与可空性嵌套 option 值需要不同的表示;独立、不可嵌套的 nullable 类型可以在适用情况下避免这次分配。
- 6原型的限制当时原型会检查注解,但在代码生成时忽略它们,因此无法对拟议布局进行有意义的基准测试。
要点建议
- 数据布局影响内存占用、缓存行为、寄存器使用和垃圾回收器的工作。
- OCaml 的统一表示支持多态和快速分离编译,但不适合该表示的值可能需要分配内存。
- 给类型添加布局注解可以暴露表示方式,同时保持语义抽象。
- 立即数和原始 64 位值即使都能放入一个机器字,对垃圾回收器的要求也不同。
- 非装箱记录和受限可空类型是拟议用途;几个示例当时尚未实现。
- 2019 年原型检查拟议布局,但仍生成旧表示,因此尚未针对这些变化进行基准测试。
按章节阅读整理后的全文
布局为什么影响性能
Stephen 利用放大的时间示例和缓存层次说明内存访问的成本。他解释编译器如何选择表示和寄存器。
Stephen 介绍一项由性能驱动的提案,旨在降低 OCaml 对装箱值的依赖。他强调,非装箱的目标是速度,然后借用 Brendan Gregg 的方法:将极短的时间乘以 10 亿,让它们的相对差异更容易感受到。
按这个比例,简单算术可能需要几秒,系统调用需要几分钟,SSD 访问约需一小时,启动一个简单程序则需要几天或更长,取决于其运行时。跨大西洋 ping 会延伸到几年。具体数字取决于机器,但差异跨越许多个数量级。
内存也有层次。访问 L1 缓存远快于访问主内存,而转换后备缓冲区未命中可能需要额外查找。更小的数据可以放进更快的缓存层,因此语言表示值的方式会实质性影响性能。
编译器必须选择一个值占多少字节、如何排列、通过哪些寄存器传给函数,以及垃圾回收器是否必须追踪它。数组指针、8 字节浮点数和 4 字节整数都有不同的自然表示方式。
在他讨论的 OCaml 表示中,int32 和浮点值却可能需要单独的堆块来保存内容。额外的块和指针会消耗空间和工作量。这并非偶然的设计错误,而是为了解决具体类型未知的值所带来的难题。

未知类型问题
多态函数和抽象类型必须在不知道具体类型的情况下编译。统一表示和按类型特化各有不同的成本。
未知类型出现在 map 等多态函数中,也出现在代码使用模块的抽象类型时。编译器必须在不知道列表元素或抽象值究竟是什么的情况下生成可运行的代码。
一种方案是为所有值使用统一布局,这里将其与 Lisp 联系起来;另一种是在具体类型已知后采用 C++ 风格的特化。OCaml 使用带标签的机器字:在 64 位机器上,最低位区分立即整数与指针。无法放入这种表示的值,如完整宽度的浮点数,必须装箱。
对每种用途进行特化会增加编译时间和代码大小,损害 OCaml 的快速构建。更根本的是,高阶程序和多态递归程序可能让所需特化的集合难以确定,甚至不是有限集合。因此简单的 C++ 风格方案通常不够。
当编译器恰好知道类型时自动使用特化布局,在其他地方使用统一布局,也有代价。隐藏类型的抽象可能意外改变性能,边界处的自动装箱也会引入不可预测的开销。Stephen 提议改为在类型系统中显式表达表示信息。
将布局加入类型系统
该提案用 value、bits64 和 immediate 等布局注解类型。这些注解可以与抽象类型、推断和模块接口配合使用。
早期原型识别一种以 64 个原始位表示的非装箱 int64。与普通值不同,这些位通过整数寄存器传递,绝不能作为指针交给垃圾回收器。Stephen 将相应布局称为 value 和 bits64;随着演讲推进,示例越来越偏向设想。
模块接口可以声明一个具有 bits64 布局的抽象订单 ID,而不暴露其定义。其他代码不能把该 ID 当作普通整数,但可以使用为 64 位键设计的哈希表。当需要 bits64 键却传入 value 布局的字符串时,类型检查器会拒绝。

类型参数通常默认为 value 布局,但可以约束为 immediate。立即值始终以带标签的整数形式表示,绝不是垃圾回收器指针。只允许此类值的引用在读取或修改时,无需通知回收器指针图发生的变化。
布局约束参与类型推断:构造仅限 immediate 的引用的函数,本身也必须接受 immediate 类型,而不能接受所有 value 类型。模块签名也利用这种关系:适用于所有 value 类型的函数可以服务只需要 immediate 类型的调用方,但反过来通常不成立。

Stephen 说,这套方案应能融入 OCaml 现有的模块系统、多态和推断机制。预期效果是在保持语义细节抽象的同时,暴露编译所需的布局信息。
记录与可空值
拟议的非装箱记录可以内联存放字段。独立的 nullable 类型可以通过静态禁止嵌套来避免 option 分配。
一个拟议用途是包含两个非装箱 64 位坐标的点记录。普通 point 仍是指向记录的装箱值,而非装箱版本可以作为两个原始组件传递。在拟议的嵌套表示中,由两个非装箱点组成的线可以内联包含四个 64 位组件。

另一个可能用途涉及 option。构造 Some x 通常会分配一个盒子。这项成本有原因:OCaml 必须区分 None、Some None、Some (Some None) 等值。嵌套 option 很有用,例如哈希表查找必须区分键不存在和键存在但其值为 None。

Stephen 提议使用一个独立的 nullable 类型,其参数必须是 non_nullable。字符串可以表示为存在或不存在,而不支持再嵌套一层 nullable;尝试嵌套会产生静态类型错误。由于嵌套 option 有用,这会是新类型,而不是悄悄改变 option。

到目前为止展示的布局都是固定的:一个定义生成一段编译代码。通过类似宏的机制为多种布局生成独立实现可能有用,但 Stephen 将这一想法称为推测,远超当时已有的原型。
问题与设计权衡
讨论涵盖调用约定、子种类关系、分离编译、垃圾回收和其他拟议扩展。Stephen 区分了已经实现的类型检查和未来工作。
问答中,Stephen 解释传递大型非装箱记录类似传递许多参数。后端可以先使用寄存器,寄存器用完后再使用栈。他将设计与 Haskell 的 levity polymorphism 工作比较:此提案更多依赖子种类关系,例如 immediate 是 value 的子集。
为所有 value 布局编译的函数也能处理 immediate 值,因为 immediate 是 value 的子集。这并不意味着 value 与 bits64 可以互换:两种表示确实不同,不能基于这个推理共享同一个实现。
被问到为什么编译器不能直接穿透抽象或使用全程序编译时,Stephen 说,与性能有关的布局应在源代码接口中可见。全程序方法拥有更广阔的优化设计空间,但不同表示会使链接分离编译的代码变得复杂。保持快速分离编译和增量编译是一项重要约束。
他说,在 32 位机器上,后端可以将一个 64 位值拆分到两个寄存器,并生成必要操作。多态函数针对特定布局编译;它可以接受该布局内的不同类型,但一个实现不会自动跨不兼容布局实现多态。
谈到非装箱变体,Stephen 说团队希望支持它们,但尚未最终确定设计。混合原始数据和垃圾回收器指针的变体会引出棘手的标签和回收器问题,因此可能需要限制。回收器是精确的:绝不能让它把原始 bits64 当作指针追踪。
装箱和非装箱形式之间需要显式转换,尽管语法尚未选定。原型对编译器性能的影响还没有经过认真基准测试或优化,不过 Stephen 在开发中没有注意到明显变慢。
拟议的 nullable 类型也是建立在部分实现原型之上的未来工作。垃圾回收器对堆块的现有限制影响了拟议布局;放宽指针与非指针混合表示将是另一个独立项目。
一位观众问到现有浮点数组的特殊情况。Stephen 说,可以编码一个非浮点布局来保持高效数组,但他更愿意避免延长这一特殊情况的寿命。他还提到一份用于定义特化数组类型的旧编译器补丁,未来可能与非装箱值配合使用。
当时的原型会进行布局类型检查,并能判断带注解程序的拟议方案,但随后忽略注解,仍按原来的方式编译。因此还无法对新表示进行基准测试。包括默认值和推断在内的许多设计决定仍未确定。
最后,一位观众问,选择深层嵌套字段是否会强制复制大型非装箱记录。Stephen 说,编译器可以识别投影或消除未使用的加载;团队必须注意避免不必要的复制,但他不认为这需要类似 Rust 的借用系统。
同频道的其他文章
15:33EN → 简体中文
1:48:29EN → 简体中文
1:36:37EN → 简体中文
1:05:45EN → 简体中文
1:25:00EN → 简体中文
1:16:29EN → 简体中文