Rust 终于学会“无痕转身”:一个实验性关键字,居然让解释器跑赢手写汇编

当 Rust 开始“尾调用”,解释器世界有点热闹了
如果你平时不写编译器、虚拟机,看到“尾调用解释器”这几个字,大概率会想把页面关掉。但这次我建议先别急。因为这不是一篇单纯面向语言极客的技术笔记,而是一则很有时代意味的信号:Rust 夜间版里一个名叫 become 的实验性关键字,正在把“高性能、低层控制、同时还尽量安全”这三件过去很难兼得的事情,拧到一起。
事情的主角是开发者 Matt Keeter。他一直在折腾 Uxn——一个极简却很有生命力的小型栈式虚拟机,背后承载着 Hundred Rabbits 生态里不少应用。Uxn 不是那种会登上消费电子头条的明星技术,它更像独立开发者圈子里的手工机械表:结构简单、设计克制、爱它的人会非常爱。Keeter 这些年持续优化 Uxn 的 Rust 实现,先是普通解释器,后来为了性能硬是手写 ARM64 汇编,又把汇编后端移植到 x86。结果这一次,他用纯手写、但不含 unsafe 的 Rust 尾调用版本,在 ARM64 上反超了自己那套手调汇编。
这就很有戏剧性了。程序员圈子里有一种近乎宗教般的朴素信仰:真要拼极限性能,最后还是得下沉到汇编。可现实一次次提醒我们,汇编当然仍是武器,但它不再总是王炸。尤其当编译器已经能理解你的真实意图,并愿意替你把寄存器、跳转、调用约定这些繁琐体力活干到位时,高级语言未必就注定慢半拍。
一场关于“解释器该怎么跳”的老问题
要理解这件事为什么重要,得先看解释器性能的老毛病。像 Uxn 这样的虚拟机,本质上是在不停重复一个动作:读下一条指令,执行,再跳到下一条。最直白的写法,是一个大循环加一个 match 分发。可问题在于,这种中心化的分发逻辑,对现代 CPU 的分支预测器并不友好。每次都要从同一个地方重新判断“下一条是谁”,容易让流水线磕磕绊绊。
所以很多高性能解释器会采用一种老派但有效的办法:threaded code,中文常被译作“线程化代码”或“穿线代码”。别被名字吓到,它和多线程没关系。它的精髓是把“跳到下一条指令”的动作分散到每个 opcode 的结尾去做。每条指令执行完,直接跳到下一条指令对应的实现函数,而不是回到一个总控中心排队。这样做的好处,是 CPU 更容易学会程序里常见的指令序列,分支预测也更稳定。
Keeter 之前的 ARM64 和 x86 汇编后端,本质就是这么干的:把虚拟机状态尽量塞进寄存器里,每个 opcode 末尾自己去 RAM 里拿下一条 opcode,再直接 jmp 或 br 过去。这种做法速度非常可观,ARM64 上能快 40% 到 50%,x86-64 上甚至能接近翻倍。但它的代价也很典型:代码量大,维护痛苦,而且危险。作者自己就提到,在 x86 移植里曾出现过越界写,踩坏了设备内存之外的几个字节,症状却离谱到只有 fuzzer 在退出某个特别程序时才会段错误。你看,这就是汇编世界的日常——跑得快,但摔起来也是真疼。
一个关键字,把“函数调用”变成了“直接接棒”
这次的新思路,漂亮就漂亮在它抓住了 threaded code 的核心,但尽量不再用汇编来实现。方法听上去很简单:既然我们想让虚拟机状态常驻寄存器,那就把这些状态作为函数参数传来传去;既然每条指令执行完都该直接跳到下一条,那就让函数在结尾尾调用下一个函数。
从概念上说,这并不新鲜。尾调用优化在函数式语言世界里都快讲烂了,Scheme、ML、Haskell 派系对它并不陌生,一些解释器和虚拟机实现里也早就有人玩得很深。但在 Rust 语境里,它一直更像“理论上可以,工程上不太顺手”的东西。原因很现实:即使你写出了尾调用形态,编译器也未必承诺真的把它变成不增长栈帧的跳转。于是你会得到一个表面优雅、实际上跑着跑着堆栈爆炸的程序。
Keeter 遇到的就是这个问题。最初版本用普通函数返回下一个 opcode 对应函数,逻辑没错,可执行一段曼德勃罗渲染程序后,栈直接溢出了。问题不在算法,而在编译器没有把这些调用真正处理成 tail call。夜间版 Rust 新引入的 become 关键字,恰恰就是为这个场景开门:它明确告诉编译器,“这里不是普通调用,请把当前栈帧直接替换成被调函数的栈帧。”说白了,这不是“再打一通电话”,而是“我把话筒直接递给下一个人”。
神奇的地方也在这里:代码只改了一个词,行为却从“迟早栈溢出”变成了“真的像 threaded interpreter 一样运行”。而且 Rust 给出了语义保证,不是碰运气吃编译器实现细节。这种保证很关键,因为性能技巧一旦靠玄学,团队里很快就没人敢碰;可一旦写进语言契约,它就有机会走进正经工程。
比手写汇编还快,这不是童话,但也别高兴得太早
最吸引眼球的,当然还是性能结果。在作者的 M1 MacBook 上,这套尾调用解释器跑 Fibonacci 微基准只要 1.19 毫秒,手写汇编是 1.32 毫秒,传统 VM 则是 2.41 毫秒;渲染 Mandelbrot 时,尾调用版 76 毫秒,汇编版 87 毫秒,普通 VM 125 毫秒。也就是说,在 ARM64 上,Rust 夜间版这套方案不只是“接近汇编”,而是实打实超过了。
这背后其实不只是 Rust 的功劳,也有 ARM64 架构本身的助攻。ARM64 拥有相对充裕的寄存器资源,调用约定也更适合把一串状态参数稳稳地放在寄存器里传递。对这种“把解释器状态摊在函数参数上”的写法,它天生更友好。编译器也很争气,把构造和拆解 UxnCore 这类看起来很啰嗦的样板代码几乎全优化掉了,最后生成的汇编已经相当接近人工手调版本。
但故事到 x86-64 就没那么圆满了。尾调用版依然明显快过普通 VM,却还是输给了手写汇编,尤其在 Fibonacci 这种微基准上差距更显眼:尾调用版 3.23 毫秒,汇编版 1.84 毫秒。这里的现实也很有代表性:一项语言特性是否“够神”,常常不是由语言本身决定,而是由目标架构、调用约定、寄存器数量、编译器后端成熟度共同决定。作者提到,如果不使用 extern "rust-preserve-none" 这样的调用约定,x86 上寄存器根本不够分,开销会一下子上来。换句话说,这不是一个“从今天起 Rust 自动秒杀汇编”的故事,它更像是“在对的架构和编译器组合下,Rust 开始能摸到以前摸不到的天花板”。
这件事真正重要的,不只是快了几十毫秒
我觉得这篇文章最耐人寻味的地方,不是基准测试里谁赢了谁,而是它折射出编程语言演进的一条老路线:先让少数高手在汇编里证明一种技巧可行,再让编译器和语言把这件事制度化、普及化、去风险化。内联汇编、SIMD、协程、生成器、零成本抽象,很多能力都走过类似路径。今天轮到的是尾调用解释器。
这对虚拟机、模拟器、脚本引擎甚至数据库执行器都可能是个好消息。因为这类系统长期面临一个尴尬选择:想要高性能,就得接受更底层、更危险、更难维护的实现;想要可读性和安全性,就得忍受一部分性能损失。如果 Rust 未来能把 become 及相关 ABI 支持打磨稳定,那么一大批原本要靠 C 加汇编硬撑的项目,可能会多出一个更均衡的选项。
当然,争议也不会少。第一,become 目前还在 nightly,实验特性天然意味着接口、语义、支持平台都可能继续变化。今天它在一个 Uxn 解释器上表现惊艳,不等于明天你拿去改 Lua、Wasm、JVM 的某段热路径也能一招鲜。第二,尾调用解释器会把状态拆成大串函数参数,代码结构并不天然优雅,作者自己都用宏把样板藏起来了,宏展开后那副样子,坦白说有点“只要能编译,别问为什么”的味道。第三,工程团队是否愿意押注夜间版 Rust,也是个现实问题。很多公司对 nightly 的容忍度,远没有独立开发者那么高。
不过我还是愿意乐观一点。过去几年,解释器和运行时领域很明显在重新升温。从 Wasm 生态、轻量级嵌入式 VM,到 AI 推理系统里的 DSL 执行引擎,大家都在重新思考“解释执行真的注定慢吗”。这篇文章给出的答案很朴素:未必。只要语言愿意提供更接近机器真实行为的表达方式,解释器未必要永远扮演那个被 JIT 和汇编压着打的角色。
而且,还有一个微妙但很打动我的点:作者特意强调,这次尾调用代码是完全天然手写、100% safe Rust,没有 unsafe。在今天这个技术叙事动不动就被“AI 帮你生成”“自动化替你完成”的时代,这种老老实实钻进代码、和编译器掰手腕、最后还把结果讲清楚的写法,反而有点珍贵。它提醒我们,编程语言真正迷人的地方,不只是语法糖越来越甜,而是它偶尔会给你一个瞬间:你明明站在高级语言里,却摸到了几乎和机器裸奔一样的速度感。
如果非要把这件事压缩成一句话,我会这么说:Rust 没有发明尾调用,但它正在把尾调用从论文里的概念、高手手里的偏门技巧,慢慢变成普通系统开发者也能认真考虑的武器。这比一次 benchmark 胜负,更值得记住。