尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Racket迁移Chez Scheme后端:编译器重建与性能优化实战

Racket迁移Chez Scheme后端:编译器重建与性能优化实战 Racket 8.0 发布那天社区里最热闹的讨论不是新加了哪个库而是默认运行时从自研的 bytecode 虚拟机换成了 Chez Scheme 后端。这可不是换个依赖那么简单——这等于把一栋住了二十年的房子的地基抽出来在原坑上重新浇筑。整个过程横跨好几年新老两套实现长期并行编译器、运行时、GC、线程调度全部经历了一轮彻底返工。这篇经验报告我把“在 Chez Scheme 上重建 Racket”这件事从动机到设计、从细节到踩坑完整拆一遍给做语言实现和编译器后端迁移的朋友提供一份可直接对照的参考。1. 为什么要动这场大手术旧 VM 的天花板1.1 旧架构其实并不旧只是老了先说清楚 Racket 原来的实现长什么样。在 Racket 8.0 之前官方实现被社区戏称为 Racket BC注意这里的 BC 不是“Before Christ”而是 bytecode 的缩写。它的结构是典型的分层最前面是 reader 和宏展开器expander这层是用 Racket 自己写就的编译成字节码后运行中间是 C 写的运行时核心负责对象模型、垃圾回收、端口、操作系统接口后面是一个字节码解释器外加一个叫 JiT 的 JIT 编译器用来把热点路径变成机器码。这套架构本身很扎实当年也是设计了很久的。但问题在于Racket 是一个极度依赖宏展开的语言所谓“语言即库”几乎每个语法特性都要过一遍宏展开器。而宏展开器本身是 Racket 程序在 BC 上跑就意味着一层一层解释执行或者被 JIT 部分编译。换句话说你的程序还没开始跑业务逻辑光是展开 syntax-rules、pattern matching、contract 这些宏就已经付出了大量解释器开销。我印象里早期测过一个典型的 Racket 程序编译期和运行期的时间消耗里宏展开能占掉相当可观的比例这在 JIT 覆盖面不够的情况下是实打实的浪费。1.2 痛点不只是性能而是三层叠加很多人以为换 Chez 是被性能逼的但以我参与这个项目复盘下来的感觉性能只是最后一根稻草。真正的痛点有三个它们是层层叠加的。第一层是维护成本。BC 的运行时核心是 C光 GC、调度器、端口系统这几块就够一个小团队常年维护。Racket 团队本来人就不多又要写语言特性又要调 VM又要处理平台兼容人力被严重稀释。第二层是扩展瓶颈。JiT 的指令集是有限的每当运行时想加一个新能力比如新的内存管理策略、新的并发原语都得先问一句“JiT 支不支持、解释器要不要同步改”。这套机制决定了很多优化想法在 BC 上根本落不了地。第三层最隐晦叫“假设的沉淀”。二十年下来C 运行时里积累了无数对语义的隐式假设——比如某个结构一定不会被回收、某个栈指针一定指向特定区域。这些假设单独看都对但像藤蔓一样缠在一起之后你再想动其中任何一根都得先确认它没有拽到别的什么。所以当时摆在团队面前的选择不是“要不要换”而是“还能撑多久”。继续缝缝补补当然也能发新版但语言本身的天花板会越来越低。1.3 为什么锁定 Chez Scheme 而不是自研新 VM这个决定说出来简单论证过程其实很漫长。最自然的方案是自己重写一个更好的 VM保留 BC 的架构、换更现代的 JIT。但这条路等于把 C 运行时那套又做一遍而且 Racket 的语义太丰富自研 JIT 的工程量不是一个小团队能短期消化的。于是目光落到 Chez Scheme 上。Chez 是一套久经考验的 Scheme 实现由 Kent Dybvig 团队维护多年编译器质量极高而且它的编译器采用 nanopass 架构——几十个极小的编译 pass 串成一个完整管线。这个架构对我们要做的事太友好了你可以很容易地插入一个 pass或者修改某个 pass 的行为而不是面对一个 monolithic 的编译黑洞。更关键的是语义层面Chez 和 Racket 同属 Scheme 家族宏、尾调用、continuation 这些核心概念天然对齐语义鸿沟比“翻译到 LLVM”小得多。2016 年 Chez 开源之后许可证也合适团队里又有长期熟悉 Chez 的人这个选择题的答案其实已经摆在桌面上了。维度Racket BCChez Scheme执行模型字节码解释 JiT JIT直接原生编译编译管线自研不可见nanopass清晰可改宏展开性能解释器上展开原生代码上展开模块/phase有没有需要移植GC自研精确分代自带复制分代2. 整体设计思路前端一个字节不改后端整体推倒2.1 把“编译到字节码”改成“编译到 Chez 源码”从一开始我们就定下了一条铁律Racket 的前端——reader、宏展开器、hygiene 规则、模块绑定解析——一个字都不重写。这些代码是 Racket 二十年积累的精华重写一遍不叫重建叫自杀。正确的做法是把编译目标换掉原来宏展开完之后生成一套字节码现在宏展开完之后生成 Chez Scheme 源码。这个思路说起来轻巧但它背后有一个深刻的选择Racket 的新后端不是一个独立的编译器而是 Racket 自己。也就是说Racket 的编译器被改造成了一个“Racket 到 Chez 的翻译器”然后交给 Chez 去生成机器码。这样做的好处是所有关于宏展开的 bug 修复、新语法的支持都只需要在一份代码里完成。编译管线简化下来大概是这么个形状;; 简化后的编译管线示意 (define (compile-linklet src) (define tree (read-syntax src)) ; 1. 读取成 syntax 对象 (define expanded (expand tree)) ; 2. 宏展开到 core form (define chez-form (compile-core-chez expanded)) ; 3. core - Chez 表达式 chez-form) ; 4. 交给 Chez 的 compile 生成原生码你可能会觉得第 3 步看起来平平无奇但真正的魔鬼全在它里面。Racket 的 core form 里有大量的特殊形式比如带属性的 lambda、精确的 let-values、模块边界标记这些不能直接丢给 Chez必须按语义一条条翻译过去翻译的每一处错误都会变成运行时最诡异的崩溃。2.2 模块系统与作用域最难啃的骨头我敢说整个项目里最难的工程问题不是编译器本身而是把 Racket 的模块系统安放到 Chez 上。Racket 的模块有几个 BC 时代就固化的语义任何一条都不能妥协第一同一个模块可以被多次 instantiate每次 require 都得到一个独立的实例第二模块里的变量可以被 set!而且所有引用点必须看到最新的值第三存在 phase 分离——运行期的代码和编译期的代码在同一个文件里共存但它们之间绝对不允许互相越界访问。Chez Scheme 的顶层完全不是这个模型。它的 define 是全局的、单例的也没有 phase 的概念。如果你直接把一个模块展开成 Chez 顶层定义那么“两次 instantiate 互不影响”这条语义当场就碎了。我们的解法是把每个编译出来的模块包装成一个叫 linklet 的东西。你可以把 linklet 理解成一段所有自由变量都显式声明的代码块它不依赖 Chez 的任何顶层绑定所有外部引用都通过参数传递进来。链接器在启动时把这些 linklet 的输入输出对齐把同一个模块不同实例的变量放到独立的表里。为了访问快模块变量的引用被编译成对一张实例表instance table的索引访问而不是符号查找。这样模块的多次实例化、set! 的可见性、甚至跨线程访问的同步问题都被收敛到了“索引数组时的内存模型”这个层面而不是散落在 Chez 的全局环境里。2.3 自举先让旧的造出新的再让新的造出自己这里有个有意思的循环依赖宏展开器和编译器本身都是 Racket 程序而它们又要跑在 Chez 上但 Chez 上还没有 Racket 运行时。鸡生蛋的问题怎么解答案是分三阶段自举。第一阶段先用旧的 BC 运行时来跑这个“Racket 到 Chez”的编译器把它运行所需的运行时核心手写成一个最小的 Chez 程序得到一个能跑的 Racket CS 可执行文件。第二阶段用这个 CS 可执行文件去编译整个运行时和编译器自身——这一步完成后我们已经有了一个由 Chez 原生码构成的完整工具链。第三阶段再拿这个新的编译器去编译一次自己和上一次的输出做对比。如果两次编译结果一致说明自举达到了一个不动点系统就是“闭合”的。这个过程中最折磨人的是自举阶段的 bug 没有明确症状因为它可能来自编译器翻译错误也可能来自运行时某个基础假设不成立。我后面会详细讲我们怎么靠差异测试活下来的。3. 攻坚细节编译器的活儿好干运行时的活儿要命3.1 宏展开器的搬迁意外地轻松项目启动前大家最担心的就是宏展开器。Chez 有自己的 syntax-case 宏系统当时有人提议直接把 Racket 的 expander 重写成 Chez 风格省得维护两套。我们研究之后否了这个方案——Racket 的 hygiene 模型、scope 集合、模块绑定解析都是独一份的重写一遍是不可能保持语义一致的。结果证明这个判断没错。因为 expander 是 Racket 程序只要把运行时的核心——syntax 对象、symbol、table、struct——先搬到 Chez 上跑起来expander 就能原封不动地编译过去。真正的工作量在“先搬运行时核心”这一步而不是 expander 本身。等 expander 在 Chez 上跑起来之后收益立刻出现了宏展开从解释器/JIT 模式变成了纯原生模式大量宏重的程序编译速度肉眼可见地提升。我们当时拿几个重度依赖宏的库做基准展开时间普遍掉了好几倍那种“终于把解释器底下的引擎换成涡轮”的感觉参与过的人都会记得。3.2 绿线程、continuation 与 continuation marksRacket 的线程模型是轻量级绿色线程一个 OS 线程上可以跑成千上万个 Racket 线程配合 sync/evt 事件机制做调度。Chez 没有这套东西所以 CS 的运行时里用 Scheme 重写了一个调度器核心是基于 continuation 做栈切换。这本身不难难的是 continuation marks。Racket 的 parameterization动态参数绑定和current-continuation-marks都依赖挂在 continuation 帧上的 marks——相当于给每一次函数调用盖一个可追溯的章。Chez 的 continuation 是原生实现的没有 marks 机制。我们的做法是维护一张 side table以 Chez continuation 为 key、以 mark 集合为 value在call/cc捕获续延时做来回登记。这个方案在语义上是正确的但性能上有代价因为 continuation 在 Racket 里被大量用于实现 generator、线程切换、异常处理任何一次切换都要查这张表。我记得有一段时期benchmark 跑出来 CS 比 BC 慢最后定位到就是这张 side table 成了热路径。后来我们做了多层优化mark 集合改成了持久化数据结构读多写少的情况下只记录增量又给最常见的情形开了快速路径。这类问题在 BC 时代根本不存在因为 BC 的 VM 里 continuation 结构是我们自己设计的想放 marks 就放搬到别人家的运行时上你就得拿别人家的原语补齐所有语义这是所有“换后端”项目的共同宿命。3.3 GC 与内存管理先借用再自研GC 这块的故事值得单独说。项目早期我们直接用 Chez 自带的垃圾回收器因为它质量很好分代复制、精度也够。但 Racket 的运行时对 GC 有一堆特殊要求will executor类似终结器队列、custodian 管理的资源回收、弱引用和 ephemeron 的精确语义、还有每个 place 独立内存账本。这些东西在 BC 的 GC 里都是原生能力在 Chez 的 GC 上要么没有要么语义对不齐。所以后来团队干脆给 Racket CS 写了一层自己的 GC 支持跟 Chez 的 GC 协同工作按照对象类别决定由谁管理。这中间有个深刻的教训GC 不只是运行时的事它和编译器是咬合的。分配点在编译生成的代码里出现GC 策略一变生成代码的质量就变。如果你在做类似的后端迁移我劝你不要把 GC 当成最后一步——从设计第一天就要想清楚你的对象由谁管理、内联分配怎么表达、跨堆引用怎么处理。3.4 FFI、places 与 futures 的适配Racket 的 FFI 在 CS 上基本是靠 Chez 的foreign-procedure重写了一遍这一层相对平稳。places 走的是 OS 级进程/线程加共享内存的老路语义上问题不大。真正反复的是 futures——一开始 CS 版本直接不支持并行 future因为它的实现深度依赖旧 VM 的 JIT 机制。后来才在 Chez 上重新捡起来。如果你在社区里看到有人说“Racket CS 的 futures 后来才支持”别惊讶那段时间我们在文档里白纸黑字写明并行 future 不可用这在当时是让不少人失望的取舍。4. 排查实录我们踩过的大坑与排查方法4.1 性能回退生成的代码不像人写的问题就在那CS 刚能跑通全量测试时性能数据非常难看有相当一部分 benchmark 比 BC 还慢。我们当时排查的思路是“让编译器输出变成可读文本”。Chez 的优势在这里体现出来了——编译的是源码不是机器码所以我们可以直接看编译器生成出来的 Chez 程序长什么样。一旦看到生成的代码问题往往一目了然。比如早期版本里模块变量访问被编译成对实例表的索引但每次索引都带边界检查这在热循环里就是灾难。又比如某些变量应该被 unbox 成普通数字但因为 set! 的语义太开放保守的编译器会把它们全部 box 起来。这类问题没有任何魔法解法就是逐段编译、逐段看生成的 Chez 代码、逐段优化。我后来总结了一条经验如果一个生成出来的代码片段人眼一看就知道“这不是我写的”那它十有八九需要改好的代码生成器应该让人类读者都能猜到一个变量会被放在寄存器还是堆里。4.2 语义不变量最阴险的 bug 都藏在边界里这里列几个我们付出过惨痛代价的语义问题都是典型的“看起来对细想不对”。第一个是 set! 的可见性。模块变量在 CS 里落到了实例表数组上那就成了可变数组下标。如果两个线程同时 set! 同一个模块变量你就必须保证内存模型正确不能偷懒拿普通数组访问直接糊弄否则多核环境下会出现“纸面上正确跑起来随机错”的幽灵 bug。第二个是 Chez 顶层 define 的阴影语义。Chez 的顶层文件加载有一个规则后定义的变量可能“阴影”前面对它的引用这个行为在单文件里是明确的但跨文件、跨加载顺序时很容易踩到。我们一开始写编译后端时图省事在生成代码里用了少量顶层 define结果不同加载顺序下程序行为不一致。最后痛定思痛linklet 里完全禁用顶层变量所有绑定走显式参数才把这个坑填上。第三个是 phase 分离。Chez 没有 Racket 那种编译期/运行期的 phase 划分但我们不能因此放弃宏里的编译期计算。解法是把编译期要执行的代码也编译成一个 linklet放到宏展开环境里去实例化。听起来是绕路但这正是给“另一套运行时”打工的常态你没有那个能力就用自己的能力把它的语义仿真出来。问题表象根因解决方案set! 可见性多线程下随机错误数组访问缺内存屏障实例表访问加同步语义顶层阴影加载顺序影响行为依赖 Chez 顶层 definelinklet 全显式绑定phase 分离宏的编译期计算失败Chez 无 phase 概念编译期代码独立 linklet 实例化continuation marks热路径性能回退side table 开销持久化结构 快速路径4.3 测试方法论靠差异测试活着在整个迁移期间我们的底气来源是一套笨但极其有效的策略同一份 Racket 代码库分别跑在 BC 和 CS 上跑同一个测试集两边输出必须一致。不一致就是 bug没有例外。我们维护了一个专门记录语义行为差异的列表一开始里面塞满了条目随着迁移推进逐条清零。清零的过程很痛苦但清零之后有一个额外收获——很多 BC 时代没人注意过的模糊语义被彻底拷问了一遍相当于做了一次全语言的语义审计。另一件值得说的是随机化测试。我们给 contract、struct、宏展开器写了一些随机生成器专门制造边界情况比如嵌套深度极大的宏调用、交替出现的 set! 和 continuation 捕获。这种测试在人工写的测试集之外抓到了大量问题。如果你也准备做类似的移植我强烈建议把“差异测试”作为第一优先级工程来建它的性价比远超任何代码审查。5. 复盘值不值以及哪些经验能带走5.1 数据说话结果是什么Racket 8.0 把 CS 设为默认运行时这件事本身就是最大的结论。迁移完成后常见 benchmark 里大部分场景都比 BC 快尤其是宏展开和动态类型代码数值计算类在早期反而有波动经过优化之后也逐步追平并反超。编译产物从字节码变成本地机器码之后构建系统也简化了原来 JIT 那套复杂的缓存和失效逻辑直接消失。更关键的是维护侧新运行时大部分代码是 Scheme 写的参与门槛比 C 内核低得多团队把释放出来的人力投入到了语言特性上而不是无穷无尽地修 VM 边界问题。5.2 能带走的工程经验清单最后给想给语言换后端的同行一份清单这些都是这个项目里我们用血换来的第一正确性优先性能后调。先把语义对齐让所有测试在两边都能跑通再谈优化。顺序反了会陷入“性能和正确性互相污染”的泥潭。第二保留 oracle。不要急着删旧实现旧实现是差异测试的基准是你的“语义真值表”项目早期尤其如此。第三小步自举。别设计一个巨大的完美方案再动手找一条“能走通的最小路径”哪怕先牺牲一点性能先把闭环打通。第四生成代码必须可读。编译器输出的中间代码要是人能读的否则任何问题都只能靠猜。第五工具链跟进度。调试器、profiler、trace 输出这些工具要同步投入不要等到出问题了才想起来没有趁手的家伙。第六多和上游维护者交流。Chez 的维护者们帮我们解决了不少本可以算作“下游自找麻烦”的问题这种合作对双方都有价值。最后说点私人体会。这个项目给我最大的冲击是“重写”和“重建”之间有本质区别。我们几乎没有新写任何宏展开逻辑但运行时里的每一个隐藏假设都被翻出来仔细审视了一遍。所谓换后端其实就是一次对“这门语言到底依赖了什么”的彻底盘点。如果你也想做类似的事我建议先从写一份“运行时假设清单”开始——你会发现大多数问题早就安静地等在那里只等你哪天动地基的时候一起爆发。
返回列表