30 秒看懂
Richard Eisenberg 使用 Jane Street 的实验性 OCaml 编译器,展示局部值如何减少垃圾回收分配。他构建并测量一个列表处理循环,然后改写迭代器和初始化函数,以表达局部性。 编译器错误揭示了受局部约束的变量与局部分配的数据之间的区别、回调和闭包的作用,以及 exclave 如何让函数在自身区域之外产生局部结果。
- 1测量分配示例使用 OCAMLRUNPARAM=v=1024,在程序退出时打印分配和回收统计信息。
- 2支持局部值的迭代器列表及其元素不能逃逸,因此迭代器和回调都需要适当的局部性保证。
- 3创建位置很重要把变量标记为局部,并不会把 List.init 已创建的列表变成局部分配。
- 4通过 exclave 返回自定义初始化函数使用 exclave,使返回的列表在函数自身区域之外分配。
- 5解读结果修正调用并将相关值局部化后,示例报告垃圾回收分配了 2,146 个字;局部内存仍会分配,并按区域回收。
要点建议
- 局部值不能逃出其区域,因此短生命周期的内存可以在区域结束时回收。
- 受局部约束的变量仍然可以持有原本在全局分配的值。
- 使用局部列表的函数,也必须对接收局部元素的回调施加适当的局部性约束。
- 显式局部性注解可以揭示编译器原本推断出的保证。
- 返回局部结果时,exclave 允许表达式在函数自身区域之外分配。
- 垃圾回收分配计数下降,并不意味着程序不再分配内存。
按章节阅读整理后的全文
列表分配实验
Eisenberg 构建可重复的循环,创建列表并统计五的倍数;运行时统计提供垃圾回收分配的基线。
Richard Eisenberg 演示 OCaml 局部值如何减少垃圾回收分配。防止值逃出区域本身就有用,Jane Street 也关注控制短生命周期值的分配位置所带来的性能收益。
演示使用 Jane Street 的实验性编译器分支。视频录制时,团队计划与社区合作,将这些功能合入上游。示例创建许多短列表,并统计其中能被五整除的元素。
函数运行 10,000 次循环。每次迭代随机选择一个小于十的长度,用 List.init 创建由小于 100 的随机整数组成的列表,然后遍历列表,遇到能被五整除的元素时递增一个引用。固定随机种子使结果可以复现。
为了查看分配成本,Eisenberg 查阅 OCaml 运行时文档。将 OCAMLRUNPARAM 设为 v=1024 后,程序退出时会打印垃圾回收统计,包括分配的字数。最初的字节码运行报告约 214,000 个分配字;减少迭代次数后,计数也会下降。
让迭代器支持局部值
循环体定义区域,局部值不能逃逸;自定义迭代器和回调表达所需的局部性。
每个列表只在其所属的循环迭代中需要。在手动管理内存的语言中,可以在那里显式释放它;局部值则旨在不手动释放的情况下表达生命周期。每个函数和循环体都定义一个区域,局部值不能逃出其区域。

最初把列表标记为局部后,将它传给 List.iter 会产生逃逸错误。在演示使用的编译器和库版本中,标准库函数没有表达所需的局部性。Eisenberg 编写递归的 iter:处理空列表,或对表头应用函数并递归处理表尾。
自定义迭代器能够编译,但分配计数仍然很高。他从最初使用的字节码编译器 ocamlc 切换到为机器生成本地代码的 ocamlopt.opt。计数下降了,但距离他希望达到的接近零垃圾回收分配仍有很大差距。
为了理解迭代器的行为,Eisenberg 写出显式类型注解。没有注解时,编译器可以推断列表不会逃逸;不带 local 注解的类型则不提供这种保证。因此编译器保守地认为列表可能逃逸,并拒绝该调用。

给列表参数添加 local 注解解决了类型的这一部分。局部列表的元素也不能逃逸,因此回调同样必须接受局部元素;否则它可能把值保存到其他地方。添加这两处注解后,迭代器通过类型检查。
创建局部列表
局部变量可以包含全局分配的列表;初始化函数的显式类型和 exclave 解决局部性与尾位置错误。
即便如此,程序仍在分配。局部变量被赋予 List.init 创建的列表,而该函数的结果不保证在局部分配。全局值可以在局部使用:限制变量使其不能逃逸,不会追溯改变值的分配位置。

为了让列表从创建时就是局部的,Eisenberg 编写替代的 init。递归循环接收索引和累加器,对每个索引应用函数,再将结果用 cons 加到累加器上。循环从长度减一开始,在索引小于零时结束。
实际调用替代函数后,它最初能够编译并产生预期结果 8,987。但没有局部性注解时,它并不能解决分配问题。他添加类型,说明生成元素的函数返回局部值,而 init 返回局部列表。
随后编译器报告某个值逃出了区域。递归循环需要局部累加器,但返回或尾位置又引出了另一个问题。一个非尾调用注解暂时让他更进一步,不过 Eisenberg 希望保留尾调用,因此之后重新处理这个问题。
他为循环给出显式类型,要求它接收并返回局部列表。一个语法错误说明 local_ 与函数箭头相关联:括号放错位置会让注解无法作用于箭头。修正之后,编译器又指出返回处有一次逃逸。

每个函数都定义一个区域,但返回的列表应当在函数自身区域之外分配。Eisenberg 添加 exclave,让该表达式提前结束区域,并去掉临时的非尾调用注解。外层 init 也需要 exclave 来返回其局部结果。
这些修改使程序通过编译。局部版本报告约 90,000 个分配字,切换回 List.init 后则约为 177,000。减少的数量有价值,但小于他的预期,因此他进一步检查函数参数和闭包。
闭包与最终测量
局部函数参数和局部递归循环进一步减少分配;区分区域分配与垃圾回收计数。
iter 和 init 都接收函数。生成随机值的函数没有捕获环境中的任何内容,因此编译器可以避免为其分配闭包。不过迭代器的回调引用计数器引用,捕获了环境中的状态。
他也将函数参数标记为局部。这暴露了另一个逃逸问题:递归循环捕获了局部函数,因此循环本身也必须是局部的。局部循环不能在 exclave 中用于其区域之外;将 exclave 向外移动解决了这一冲突。
起初测量结果仍没有改善,因为他不小心又调用了 List.init。将调用改为自定义 init 并重新编译后,程序报告的垃圾回收分配很少:屏幕显示分配了 2,146 个字,而结果仍为 8,987。

关键区别在于,局部值仍然会分配内存。计数器并不是说程序不使用内存,而是表明由自动垃圾回收处理的分配大幅减少。每个循环区域结束时,其局部内存可以作为一组回收,从而减少回收器的工作,并可能改善延迟。
Eisenberg 最后指向示例代码,并表示下一段视频将进一步解释其工作原理以及内存去了哪里。演示中的数字适用于这个小程序以及视频中使用的实验性编译器。
同频道的其他文章
1:34:29EN → 简体中文
1:34:36EN → 简体中文
15:48EN → 简体中文
1:48:21EN → 简体中文
1:20:57EN → 简体中文
55:03EN → 简体中文