WATaBoy:将Game Boy指令即时编译为Wasm,性能超越原生解释器

Lobsters Hottest 新闻

摘要

本文介绍了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 更频繁地回退到解释器。

相似文章

Theseus: 将 win32 翻译为 wasm

Lobsters Hottest

将 Windows 可执行文件 (win32/x86) 翻译为 WebAssembly 以在浏览器中运行,讨论诸如阻塞与异步设计等挑战。

weblings:在 WASM 内部将 Rust 编译为 WASM

Lobsters Hottest

Weblings 是一个编译为 WebAssembly 的 Rust 编译器工具链,通过带有 playground 和 Rustlings 练习的网页 UI,支持在浏览器中直接编译和执行 Rust 代码。