一个Rust写的字节码虚拟机,用尾调用(tail-calling)风格实现,居然比手写汇编还快。Matt Keeter在自己的博客上发了这组数据,结论是:WebAssembly的尾调用支持有问题,V8、SpiderMonkey、Wasmtime都栽了。

但有人不服。Wastrel的作者重跑了一遍实验,数据把故事整个翻过来了。

1.5倍 vs 6.5倍:同一套代码,不同引擎差出4倍

1.5倍 vs 6.5倍:同一套代码,不同引擎差出4倍

Matt的测试对象叫Raven,一个教学用的字节虚拟机。他写了三个版本:Rust写的switch-based解释器、Rust写的尾调用解释器、以及手写汇编版本。在原生编译环境下,尾调用版本比switch-based快1.5倍,几乎追平手写汇编。

问题出在WebAssembly。Matt把同样的Rust代码编译成Wasm,分别用V8、SpiderMonkey、Wasmtime跑。结果尾调用解释器比手写汇编慢了6.5倍,switch-based也慢了4.3倍。他得出一个结论:Wasm的栈机器模型和尾调用模式八字不合。

Wastrel的作者在AMD Ryzen Threadripper PRO 5955WX上复现了这个实验,但加了一组数据——用Wastrel引擎跑同样的Wasm二进制文件。尾调用解释器的开销从6.5倍骤降到2.3倍,switch-based也从4.3倍降到2.4倍。

差距从"不能用"变成了"能用,但还有油水可挤"。

寄存器里的秘密:为什么其他引擎慢了3倍

寄存器里的秘密:为什么其他引擎慢了3倍

Wastrel作者看了眼自己生成的机器码,发现了一个可优化的点:主内存地址的struct被反复从内存加载,没能驻留在寄存器里。这是个实现细节问题,不是Wasm指令集的问题。

换句话说,V8、SpiderMonkey、Wasmtime的尾调用性能差,是具体实现的 accident(意外),不是Wasm抽象层的缺陷。尾调用解释器生成的汇编模式本身没问题,映射到Wasm栈机器上也理应高效。

Matt的原始测试其实埋了一个伏笔:他在AArch64平台上测过,尾调用解释器甚至超过了手写汇编。这说明架构差异会放大或缩小引擎的实现瑕疵,但核心模式是站得住脚的。

性能预言家的价值:当基准测试说谎时

性能预言家的价值:当基准测试说谎时

这件事的吊诡之处在于:Matt的测试完全可复现,数据也真实,但结论被推翻了。问题出在哪?

他把"Wasmtime的表现"等同于"WebAssembly的能力"。这是最常见的基准测试陷阱——测量的是特定实现,却归因于抽象规范。Wastrel用2.3倍的数据证明,同样的Wasm字节码可以跑得更快,瓶颈在引擎而非指令集。

这对开发者有实际意义。如果你在Wasm里写解释器,不必因为V8的当前表现而放弃尾调用风格。模式是对的,只是要等实现跟上。或者,选一个实现更好的引擎。

Matt的实验设计其实相当严谨:控制变量、多平台验证、代码开源。但再严谨的实验也受限于测量工具的盲区。他测了三家主流引擎,恰好漏掉了Wastrel这个异类——而Wastrel的存在,把"Wasm不行"变成了"主流引擎还没做好"。

2.4倍开销从哪来:32位指针省下的内存访问

2.4倍开销从哪来:32位指针省下的内存访问

Wastrel作者还注意到一个反直觉的点:Wasm版本理论上应该更快,因为Wasm用32位指针,原生代码用64位。内存访问更窄,缓存更友好,这部分收益抵消了一部分虚拟化开销。

但2.4倍的开销依然存在。作者推测主内存地址struct的寄存器分配是主因——每次访问内存都要重新解引用这个struct,而原生编译器能把地址直接内联到代码里。这是Wasm沙箱模型的代价,也是Wastrel下一步要啃的硬骨头。

对比数据:Wasmtime的4.3倍和6.5倍,说明它在switch-based和尾调用两条路径上都有额外的低效。可能是间接调用实现的问题,也可能是尾调用到循环的转换没做好。具体根因需要看它们的机器码生成,但现象很明确——同样的Wasm文件,不同引擎能差出近3倍。

尾调用解释器为什么快:分支预测器的甜蜜点

尾调用解释器为什么快:分支预测器的甜蜜点

回到原生环境,尾调用解释器凭什么比switch-based快1.5倍,甚至追上汇编?

核心机制是间接分支消除。switch-based解释器每次取指令都要做一次大的switch跳转,分支预测器很难猜对。尾调用风格把每个指令实现成独立函数,用尾调用串联,编译器能生成更线性的代码流。

现代CPU的分支预测器对这种模式很友好。Matt的手写汇编用了直接线程化(direct threading),本质上也是把指令地址直接编码在流里,减少预测失败。Rust编译器生成的尾调用代码,恰好撞上了类似的优化路径。

在AArch64上反超汇编,说明编译器在某些架构上比人更会利用指令级并行。手写汇编的优化假设可能和具体微架构错配,而编译器的保守策略反而更稳健。

Wastrel的2.3倍是终点还是起点

Wastrel的2.3倍是终点还是起点

作者自己说,2.3倍还有下降空间。主内存地址struct的寄存器分配是个已知问题,修掉之后可能逼近2倍。再往下,就要看Wasm的内存模型和原生模型的本质差异了。

这个案例给性能优化者的启示是:先怀疑测量工具,再怀疑自己的代码。Matt花了时间优化Raven的实现,但没怀疑过Wasm引擎的成熟度。Wastrel的数据把优化责任从"你的算法"转移到了"我的引擎"。

对于在浏览器里跑复杂计算的人,这意味着不必过早妥协。尾调用解释器在理论上是好设计,实践上只需要挑对引擎,或者给主流引擎一点进化时间。

Matt Keeter在博客评论区会怎么回应这个2.3倍?他会不会也跑一遍Wastrel验证?数据对决的下一步,可能比原始实验更有趣。