☰
Racket迁移Chez Scheme后端:编译器重建与性能优化实战
2026/10/6 13:24:48 网站建设 项目流程

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 marks

Racket 的线程模型是轻量级绿色线程,一个 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 的维护者们帮我们解决了不少本可以算作“下游自找麻烦”的问题,这种合作对双方都有价值。

最后说点私人体会。这个项目给我最大的冲击,是“重写”和“重建”之间有本质区别。我们几乎没有新写任何宏展开逻辑,但运行时里的每一个隐藏假设都被翻出来仔细审视了一遍。所谓换后端,其实就是一次对“这门语言到底依赖了什么”的彻底盘点。如果你也想做类似的事,我建议先从写一份“运行时假设清单”开始——你会发现大多数问题早就安静地等在那里,只等你哪天动地基的时候一起爆发。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询