WATaBoy:将Game Boy指令即时编译为Wasm,性能超越原生解释器
摘要
本文介绍了WATaBoy,一个Game Boy模拟器,它使用即时编译到WebAssembly的方式,实现了超越原生解释器的性能,是JIT到Wasm在模拟领域的一个概念验证。
<p><a href="https://lobste.rs/s/krqeoc/wataboy_jit_ing_game_boy_instructions">评论</a></p>
查看缓存全文
缓存时间: 2026/06/29 16:30
# WATaBoy:将Game Boy指令JIT编译为Wasm,性能超越原生解释器
来源:https://humphri.es/blog/WATaBoy/
发布于2026年6月28日
## 背景
本文假定读者了解即时编译的概念 (<https://en.wikipedia.org/wiki/Just-in-time-compilation>)。
Dolphin 无法在 iOS 上运行,因为 iOS 不允许 JIT 编译。这是 OatmealDome 的博文《为什么 Dolphin 无法上架 App Store》(<https://oatmealdome.me/blog/why-dolphin-isnt-coming-to-the-app-store/>) 中的简要总结。自从读了那篇文章,我就一直好奇:要让一个像 Dolphin 这样受 CPU 限制的模拟器在 iOS 上工作,需要付出什么代价?难道我们……只能等几年,等 iPhone 的 CPU 快得足以用解释器运行 Dolphin 吗?
不过,Apple 对 JIT 限制有一个例外:网页浏览器。JavaScriptCore(WebKit 的 JS 引擎 (<https://docs.webkit.org/Getting%20Started/Introduction.html#what-is-webkit>))在其高性能层级使用了 JIT 编译。因此,如果一个 JS 函数被调用了足够多次,它最终会被优化并编译为原生机器码。WebAssembly 也是如此。那么,如果我们直接借用这个机制呢?我们不必直接生成原生机器码,而是生成 Wasm 字节码,然后由浏览器将其编译为原生机器码。
在阅读了 Andy Wingo 的博文《在 WebAssembly 内进行即时代码生成》(<https://wingolog.org/archives/2022/08/18/just-in-time-code-generation-within-webassembly>) 之后,我知道这种事情是可能实现的。事实上,已有几个项目使用了这种技术,例如 The Jiterpreter (<https://github.com/dotnet/runtime/blob/main/docs/design/mono/jiterpreter.md>) 和 v86 (<https://github.com/copy/v86>),但截至本文撰写时,还没有任何游戏机模拟器使用它,也没有人将其性能与*在原生环境运行*的解释器进行比较,以验证它是否更快。
因此,作为我本科的毕业设计,我决定构建一个 Game Boy 模拟器,先是用解释器实现,然后再用 JIT-to-Wasm 实现。本项目主要作为概念验证和基准测试,用于比较两种方法的性能。在本文的剩余部分,我将称之为“**JIT-to-Wasm**”而不是“Wasm JIT”,以避免与 JS 引擎自身所做的事情(将 Wasm 重新编译为机器码)混淆。
WATaBoy 截图
WATaBoy 的截图,这是一个将 SM83 编译为 Wasm 的 Game Boy 模拟器
任何对模拟稍有了解的读者看到这里可能都会翻白眼,因为一个 Game Boy 模拟器怎么可能从 JIT 编译中获益?幸运的是,GameRoy 的博文 (<https://rodrigodd.github.io/2023/09/02/gameroy-jit.html>) 准确地描述了如何在保持周期精确的同时实现这一点:
- 预测中断何时会发生
- 每当一个 JIT 块可能被中断时,回退到解释器
- 对任何通过 MMIO 访问的非 CPU Game Boy 组件进行惰性求值
GameRoy 的 JIT 只面向 x86,但其绝大多数优化技术同样适用于我们的 JIT-to-Wasm。如果你对 Game Boy 模拟方面的细节感兴趣,一定要看看这篇博文;它给了我巨大的灵感。尽管如此,Game Boy 模拟器从 JIT 编译中获益的程度远不如第六代游戏机。但制作它要快得多,而且确实符合我毕业设计的范围。
## 实现
现在,为了缩小*这篇博文*的范围,我将带你了解 WATaBoy 中适用范围最广、且我在其他地方找不到指南的部分:**在 Rust 中进行 Wasm 代码生成和延迟链接**。WATaBoy 有很多有趣的地方,特别是从 Game Boy 模拟的角度(例如 SIMD 瓦片渲染),但这些实现细节值得单独写文章(当然你也可以直接阅读 WATaBoy 的源代码)。如果不感兴趣,可以直接跳到结果部分 (<https://humphri.es/blog/WATaBoy/#results>)。
通常我们会使用 wasm-bindgen (<https://wasm-bindgen.github.io/wasm-bindgen/>) 和 wasm-pack (<https://wasm-bindgen.github.io/wasm-pack/>) 这类工具来生成 Rust 和 JavaScript 之间的胶水代码。但这些工具在底层操作 Wasm 时会带来一些易用性问题。相反,我采用了一种类似《从零开始将 Rust 编译为 WebAssembly》(<https://surma.dev/things/rust-to-webassembly/>) 中描述的方法。这意味着我们将通过 C ABI 在 Rust 和 JS 边界传递数据,使用指针和缓冲区长度,而不是 JavaScript 对象。
提前说明,你需要 Nightly Rust,因为我们稍后会用到一点点内联 Wasm。所以运行:
```
rustup default nightly
```
要切回去,只需再运行一次,将 'nightly' 替换为 'stable' 即可。
创建一个新的库:
```
cargo new --lib jit-to-wasm
```
看,我们已经有一些代码了:
```
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
```
对于这个简单示例,让我们尝试在运行时生成一些做同样事情的 Wasm 字节码。
### Wasm 代码生成
wasm-encoder (<https://docs.rs/wasm-encoder/latest/wasm_encoder/>) 箱将是我们唯一的依赖。使用它,我们可以通过一种构建器模式来发出 Wasm 指令的字节。它不是为我们的 JIT 用例设计的,所以有一些易用性问题和一点点模板代码,但绝对比手动编写原始字节数组要好。:)
```
[package]
name = "jit-to-wasm"
version = "0.1.0"
edition = "2024"
[lib]
# 必须生成 .wasm 文件。
crate-type = ["cdylib"]
[dependencies]
wasm-encoder = "0.252.0"
```
现在,让我们用它来生成一个包含 'add' 函数的 Wasm 模块的字节码。这就是我提到的模板代码:
```
use wasm_encoder::*;
fn make_add_module() -> Vec<u8> {
let mut module = Module::new();
// 为 add 函数编码类型段。
// 参数:32 位整数 left, 32 位整数 right。
// 返回:32 位结果。
let mut types = TypeSection::new();
let params = vec![ValType::I32, ValType::I32];
let results = vec![ValType::I32];
types.ty().function(params, results);
module.section(&types);
// 编码函数段。
let mut functions = FunctionSection::new();
let type_index = 0;
functions.function(type_index);
module.section(&functions);
// 编码导出段。
let mut exports = ExportSection::new();
exports.export("my_add_func", ExportKind::Func, 0);
module.section(&exports);
// 编码代码段。
let mut codes = CodeSection::new();
let locals = vec![];
let mut my_add_func = Function::new(locals);
my_add_func
.instructions()
// 将第一个 32 位整数压入栈(left)。
.local_get(0)
// 将第二个 32 位整数压入栈(right)。
.local_get(1)
// 将两个整数相加。
.i32_add()
.end();
codes.function(&my_add_func);
module.section(&codes);
// 提取此模块的编码 Wasm 字节。
module.finish()
}
```
这个例子几乎与 wasm_encoder 文档中的例子完全相同。
好了,现在我们如何实际执行这个字节码呢?
```
#[unsafe(no_mangle)]
pub extern "C" fn make_and_execute_add(left: i32, right: i32) -> i32 {
let add_bytecode = make_add_module();
// 执行 add ...以某种方式???
}
```
### 编译与链接
回想一下 Wingo 的博文,Wasm 是哈佛架构而非冯·诺依曼架构。实际来说,这意味着我们无法*直接*执行程序生成的字节码。对于 WebAssembly 来说,我们必须向*嵌入器*(通常是 JavaScript)求助,以编译、实例化并链接我们的新 Wasm 字节码。
jit-interface (<https://github.com/WebAssembly/jit-interface/tree/main>) 提案可能提供一种直接在 Wasm 中通过 `func.new` 指令实现的方法,但目前我们还得与 JavaScript 沟通。
1. 首先,我们使用同步编译接口来编译并实例化我们的字节码。(编译与实例化)
2. 然后,我们将生成的模块中的函数添加到主模块的间接函数表中,并记录其在表中的索引,以便稍后调用它。(链接)
3. 最后,我们可以使用 call_indirect 指令实际执行该函数,该指令会调用间接函数表中的第 *n* 个函数。(分发)
假设我们已经导入了一个名为 "linkNewModule" 的函数,它可以编译、实例化并链接一段字节码缓冲区;我们稍后会在 JavaScript 中实现真正的函数。
```
#[link(wasm_import_module = "env")]
unsafe extern "C" {
// 返回新函数在表中的索引。
#[link_name = "linkNewModule"]
fn link_new_module(buffer: *const u8, len: usize) -> i32;
}
```
接下来,我们实现分发函数,以调用间接函数表中的第 *n* 个函数。我们真正需要做的就是执行 call_indirect Wasm 指令。通常,当你想做这种事情时,你会使用 `std::arch` 中的内联函数,但没有针对 call_indirect 的内联函数。因此,我们将不得不使用*一点点*内联 WebAssembly。这是一个不稳定的特性,所以你必须将其放在 lib.rs 的顶部:
```
#![feature(asm_experimental_arch)]
use std::arch::asm;
```
```
// 间接调用此模块函数表中索引为 `index` 的函数。
fn dispatch(index: i32, left: i32, right: i32) -> i32 {
let mut result: i32;
unsafe {
asm!(
"local.get {right}",
"local.get {left}",
"local.get {index}",
"call_indirect (i32, i32) -> (i32)",
"local.set {result}",
index = in(local) index,
left = in(local) left,
right = in(local) right,
result = lateout(local) result,
);
}
result
}
```
把它们放在一起,我们就得到了:
```
#[unsafe(no_mangle)]
pub extern "C" fn make_and_execute_add(left: i32, right: i32) -> i32 {
let add_bytecode = make_add_module();
let func_idx = unsafe { link_new_module(add_bytecode.as_ptr(), add_bytecode.len()) };
dispatch(func_idx, left, right)
}
```
还有最后一件事:我们需要通过 /build.rs 文件向 LLD 传递几个标志:
第一个标志 `--export-table` (<https://lld.llvm.org/WebAssembly.html#cmdoption-export-table>) 会导出我们主 Wasm 模块的间接函数表,以便我们可以从嵌入器(JS)访问它。
第二个标志 `--growable-table` 允许我们增长该表,以便追加我们 JIT 编译的函数。
这个标志完全没有文档,但它是有效的,而且有一个测试用例 (<https://github.com/llvm/llvm-project/blob/04025adc8c6b9fd4542e1c658ed19381d4274ea0/lld/test/wasm/growable-table.test#L2>),所以……
```
fn main() {
println!("cargo:rustc-link-arg=--export-table");
println!("cargo:rustc-link-arg=--growable-table");
}
```
好了,Rust 这边就完成了。让我们构建主 Wasm 模块:
```
cargo build --release --target wasm32-unknown-unknown
```
### 嵌入器(JavaScript)方面
现在,让我们尝试从嵌入器中调用我们的 `make_and_execute_add` 函数:
```
// 实例化 JIT 自身的主 Wasm 模块。
const source = fetch(
"target/wasm32-unknown-unknown/release/jit_to_wasm.wasm"
);
const {instance} = await WebAssembly.instantiateStreaming(source);
// 在运行时生成一个 add 函数,并用它来将 2 和 3 相加。
const result = instance.exports.make_and_execute_add(2, 3);
console.log(result);
```
控制台输出:
```
TypeError: import env:linkNewModule must be an object
```
啊对,我们还没有实现那个链接函数。现在来做:
```
const linkNewModule = (bufferPtr, bufferLen) => {
// 从主实例的内存中读取 Wasm 字节码。
const bytecode = new Uint8Array(
instance.exports.memory.buffer,
bufferPtr,
bufferLen
);
// 将字节码编译并实例化为一个新实例。
const newModule = new WebAssembly.Module(bytecode);
const newInstance = new WebAssembly.Instance(newModule);
// 将新实例的 "my_add_func" 函数添加到主实例的间接函数表中。
instance.exports.__indirect_function_table.grow(
1,
newInstance.exports.my_add_func
);
// 返回刚刚链接的函数的索引。
return instance.exports.__indirect_function_table.length - 1;
}
const importObj = {env: {linkNewModule}};
// 实例化 JIT 自身的主 Wasm 模块。
const source = fetch(
"target/wasm32-unknown-unknown/release/jit_to_wasm.wasm"
);
const {instance} = await WebAssembly.instantiateStreaming(
source,
importObj
);
// 在运行时生成一个 add 函数,并用它来将 2 和 3 相加。
const result = instance.exports.make_and_execute_add(2, 3);
console.log(result);
```
这是控制台输出:
```
5
```
这是我们在本页面上运行的代码示例:
Left: \+ Right: =
这就是 WATaBoy 的代码生成、链接和分发的基础。我相信你能猜到如何修改 make_add 中函数的签名和指令,以在运行时生成更有用的 Wasm 模块。
在 WATaBoy 中,我们的 JIT 重新编译并追加每条非分支的 Game Boy 指令,以创建一个基本块(一个包含单个 `execute_block` 函数的 Wasm 模块),我们可以缓存并稍后重新执行。如果你好奇,可以查看 Game Boy 指令集的重新编译部分 (<https://github.com/EnergeticBark/WATaBoy/blob/1fdf6ab7e5d9a7b22800d6f0cc354a1fecd8587a/jit/src/codegen/instructions/block2.rs>)。
## 结果
现在,让我们比较 WATaBoy 的 JIT 编译器在 Wasm 中运行、其解释器在 Wasm 中运行以及其解释器在原生环境中运行的性能。对于这个基准测试,所有三个变体都设置为加载一个游戏 ROM,并在无上限速度下模拟循环播放的标题画面,持续 10 秒的挂钟时间。我测试的三个 ROM 是:宝可梦 蓝、塞尔达传说:梦见岛,以及 Tobu Tobu Girl(一款免费的自制平台游戏)。结果以 10 秒内模拟的总帧数来衡量。
基准测试环境
| 环境 | 配置 |
|------|------|
| 设备 | MacBook Air (13-inch, M2, 2022) |
| 内存 | 16 GB |
| 操作系统 | macOS 26.5 (25F71) |
| Safari | 26.5 (21624.2.5.11.4) |
| Chrome | 148.0.7778.168 |
| Firefox | 150.0.3 |
| Rust 编译器 | 1.97.0-nightly (82bee9650 2026-05-09) |
| WATaBoy | 提交 c06850a |
每个基准测试配置运行了 5 次迭代。Wasm 基准测试在未打开其他标签页的网页浏览器中进行,每次迭代后刷新运行基准测试的标签页。模拟的总帧数取平均值,然后除以 Game Boy 的刷新率 (59.73) × 10,得到如下所示的相对速度。
每种方法相对于原始 Game Boy 硬件的比较
不错!在模拟宝可梦 蓝时,JIT-to-Wasm 的速度比原生运行的解释器**快了约 1.2 倍**,因此我们受益于 JIT,尽管它离原生机器码又多了一层间接。同时,它比在 Wasm 中运行的解释器快了约 1.5 倍。
我还跨三大浏览器引擎运行了基准测试,以观察它们之间的差异。
比较我们的 JIT-to-Wasm 在 Safari、Chrome 和 Firefox 上的性能
看来 Safari 领先!需要说明的是,我们的 JIT 并没有针对任何特定浏览器进行调优;开发过程中的大部分性能分析实际上是在 Firefox 中完成的。所以很高兴知道 iOS 只能使用 WebKit 并没有拖累我们(至少在这个例子中是这样)。:)
整件事情基于 Web 的一个好处是,我可以直接将演示放在这篇博文中。一个坏处是它太快了,**可能会引发光敏性癫痫患者的癫痫发作 ⚠️**,所以在按启动按钮之前请小心。
帧时间
平均值(最近 1000 毫秒) 最小值(最近 1000 毫秒) 最大值(最近 1000 毫秒)
速度:×
Tobu Tobu Girl (<https://tangramgames.dk/tobutobugirl/>) 基于 MIT/CC-BY 许可证。
## 后续工作
### WATaBoy 最显著的缺失功能是音频和 GBC 支持。
在性能方面,性能分析显示模拟 PPU 仍然占据了 WATaBoy 绝大部分运行时间,因为还有几个 PPU 中断我还没有实现预测。这导致 JIT 更频繁地回退到解释器。
相似文章
最好的WebAssembly运行时可能仍然是根本没有运行时
本文使用支持宽算术的wasm2c对WebAssembly-to-C方法进行了基准测试,证明其在速度和内存使用方面仍然与专门的WebAssembly运行时(如Wasmer和Wasmtime)具有竞争力。
将我的C游戏移植到WASM,这是我遇到的所有Bug
一位开发者分享了将C游戏移植到WebAssembly的经验,详细介绍了因32位与64位差异遇到的Bug,并提供了调试技巧。
SpaceWASM:NASA/JPL 用于航天器序列化的 WebAssembly 解释器
SpaceWASM 是一个来自 NASA/JPL 的开源 WebAssembly 解释器,专为航天器机载序列化设计,注重确定性内存使用和在资源受限环境下的流式处理能力。
Theseus: 将 win32 翻译为 wasm
将 Windows 可执行文件 (win32/x86) 翻译为 WebAssembly 以在浏览器中运行,讨论诸如阻塞与异步设计等挑战。
weblings:在 WASM 内部将 Rust 编译为 WASM
Weblings 是一个编译为 WebAssembly 的 Rust 编译器工具链,通过带有 playground 和 Rustlings 练习的网页 UI,支持在浏览器中直接编译和执行 Rust 代码。