
2026/06/30 0:02
WATaBoy:WASM でゲームボーイの命令を JIT コンパイルしてネイティブインタプリタに勝利する
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
WATaBoy プロジェクトは、Rust で記述された高性能なゲームボーイエミュレータを導入し、「JIT-to-Wasm」アプローチを採用しています:ランタイムで WebAssembly バイトコードを生成し、ブラウザの JavaScript エンジン(例:JavaScriptCore)が頻繁な呼び出しに応じてネイティブ機械語へコンパイルする方式です。本システムは
wasm-encoder クレートを使用してバイトコードを出力し、標準的な bindgen ツールではなく C ABI を介して Rust-JS 境界間でのデータ転送を実行します。動的な関数成長をサポートするため、ニghtly Rust 環境内で非公式なリンクフラグ(例:--growable-table)を使用し、asm_experimental_arch などのあるinline WebAssembly 機能を利用しています。Pokémon Blue などのゲームのタイトル画面をループ再生したベンチマーク結果では、M2 MacBook Air 上でこの手法は標準的なインタープリタ方式よりも約1.2倍高速、生の WebAssembly エグゼッションよりも約1.5倍高速であることが示されました。Safari が最も高い性能を示し、iOS の WebKit に制限が存在してもこの手法を阻害しないことを検証した一方で、本アプローチは現在 nightly Rust に依存しており、他のプロジェクトに見られるような一部の低レイヤーの最適化や、エルゴノミクスなコード生成ツールが不足しています。将来の取り組みとしては、PPU インターラプト予測、オーディオエミュレーション、Game Boy Color サポートの実装を目指し、既存のブラウザコンパイラとプロプライエタリツールを使用せずに高性能なエミュレータを構築できることを証明するものです。
- Changes made:
- Key Points List からの特定の技術詳細(
、C ABI、nightly Rust 機能)を追加しました。wasm-encoder - ベンチマーク手法の説明を明確化(ループ再生されたタイトル画面を使用)。
- iOS の制限に関する表現を「克服」するという強い主張から、「...を検証した」というより正確な表現へ調整しました。
- コード生成ツールの不足および Dolphin との比較について欠けていた制約事項を含めました。
- Key Points List からの特定の技術詳細(
本文
JIT-to-Wasm:Wasm ランタイム内の即時コンパイルを活用した Game Boy エミュレータ「WATaBoy」の実装と評価
本記事では、iOS の JIT(即時展開)制限を回避し、WebAssembly 上で最適化されたコード実行を実現する手法について解説します。具体的には、「Rust から Wasm バイトコードを生成し、それを Web ブラウザ上でコンパイル・リンキングする」アプローチ(以下 JIT-to-Wasm と呼称)を用いた Game Boy エミュレータ「WATaBoy」の構築と性能検証を行います。
1. 背景と技術的課題
1.1 iPhone における JIT の制限と例外
Dolphin エミュレータが iOS App Store で提供できない主な理由は、iOS が JIT 展開を許可していないためです。しかし、Apple は Web ブラウザに対しては例外扱いを行っており、以下のような仕組みが存在します。
- WebKit の活用: JavaScriptCore (JSC) や WebAssembly (Wasm) エンジンも内部で JIT 展開を利用しています。
- 仕組み:
- JavaScript/Wasm が呼び出されると、やがて最適化されネイティブマシンコードへコンパイルされます。
1.2 アプローチの転換:JIT-to-Wasm
既存プロジェクト(The Jitterpreter や v86)は Wasm を JIT 生成する技術を採用していますが、ゲームコンソールエミュレータへの応用例やネイティブインタプリタとの比較は希少でした。
- 本研究の目的:
- Game Boy エミュレータを「インタプリタ方式」と「JIT-to-Wasm 方式」の 2 パターンで実装し、性能差を検証する。
- 「ブラウザが Wasm をマシンコードへ再コンパイルする」と「Wasm の JIT 展開」を混同しないよう注意します。
1.3 なぜ Game Boy に JIT が必要なのか?
一般的に 64bit/128bit 演算が多い第六世代以降のコンソールとは異なり、Game Boy エミュレーションへの恩恵は小さいように思われますが、以下の手法により性能向上が可能になります。
- 割り込みタイミングの予測: JIT ブロック内で割り込みが入る場合のみインタプリタへフォールバックする。
- 遅延評価(Lazy Evaluation): MMIO アクセスなどのコンポーネントは必要になった時点で計算を行う。
- 参考:GameRoy のブログ「Game Boy Emulator JIT Implementation」における技術詳細は、最適化技術の多くが通用する良質なリソースです。
2. 実装:Rust 内での Wasm コード生成と後期リンキング
本稿では、汎用的な部分である**「Rust 内での Wasm コード生成と後期リンキング(late-linking)」**に焦点を絞って解説します。
2.1 環境準備と依存関係
wasm-bindgen など通常のバインドツールは低レベルの Wasm 操作においてエルゴノミクス面で問題があるため、C ABI を経由する手動アプローチを採用しました。Nightly Rust が必須となります。
コマンド例
# Nightly Rust をデフォルトに設定 rustup default nightly # 新たなライブラリを作成(--lib flag) cargo new --lib jit-to-wasm
依存関係は
wasm-encoder クレートのみです(ビルダーパターンとして使用)。
[package] name = "jit-to-wasm" version = "0.1.0" edition = "2024" [lib] # .wasm ファイルを生成するために必要。 crate-type = ["cdylib"] [dependencies] wasm-encoder = "0.252.0"
2.2 Wasm コード生成の例
単純な加算関数(
add)を含む Wasm モジュールを make_add_module 関数で生成します。型定義、関数登録、エクスポート、コードセクションの手動エンコーディングが必要です。
use wasm_encoder::*; fn make_add_module() -> Vec<u8> { let mut module = Module::new(); // 1. 型セクション:引数 2 つ (i32), 戻り値 1 つ (i32) 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); // 2. 関数セクション:型インデックス 0 を参照 let mut functions = FunctionSection::new(); let type_index = 0; functions.function(type_index); module.section(&functions); // 3. エキスポートセクション:"my_add_func" で公開 let mut exports = ExportSection::new(); exports.export("my_add_func", ExportKind::Func, 0); module.section(&exports); // 4. コードセクション:実装 let mut codes = CodeSection::new(); let locals = vec![]; let mut my_add_func = Function::new(locals); my_add_func .instructions() // スタックに左辺を取得 .local_get(0) // スタックに右辺を取得 .local_get(1) // 加算 .i32_add() // エンド .end(); codes.function(&my_add_func); module.section(&codes); // 完了してバイトコードを抽出 module.finish() }
2.3 コンパイル・リンキング・ディスパッチの連携
生成した Wasm バイトコードを直接実行できないため、WebAssembly ハーバード構造(別メモリ空間)を利用して、JavaScript 側でコンパイル・インスタンス化し、メインプログラムへ関数を登録します。
Rust 側の実装ポイント
- Nightly 機能:
を使用して Wasm インストラクションを直接組み立てます。asm_experimental_arch - リンキー関数: JavaScript からの呼び出しで新しいモジュールを読み込み、間接関数テーブル(Indirect Function Table)に追加します。
#![feature(asm_experimental_arch)] // 必須:Nightly 機能 use std::arch::asm; // Wasm の関数テーブルの index にある関数を間接的に呼ぶディスパッチ関数 fn dispatch(index: i32, left: i32, right: i32) -> i32 { let mut result: i32; unsafe { // call_indirect を内联 Wasm として展開 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(); // JavaScript 側で呼ばれるリンキング関数へのポインタ let func_idx = unsafe { link_new_module(add_bytecode.as_ptr(), add_bytecode.len()) }; dispatch(func_idx, left, right) }
ビルドスクリプト (build.rs
)
build.rsLld リンカーに追加フラグを渡して、間接関数テーブルのエクスポートと拡張性を確保します。
fn main() { println!("cargo:rustc-link-arg=--export-table"); // 間接関数テーブルをエクスポート println!("cargo:rustc-link-arg=--growable-table"); // テーブルの成長許可(未完全ドキュメントだが機能) }
ビルドコマンド:
cargo build --release --target wasm32-unknown-unknown
3. 埋め込み側(JavaScript)の実装
Wasm バイトコードを読み込み、ブラウザ上の WebAssembly インスタンスとしてコンパイルし、間接関数テーブルへ追加する処理を行います。
3.1 リンキング関数の実装例
// メイン Wasm モジュールをインスタンス化する const source = fetch("target/wasm32-unknown-unknown/release/jit_to_wasm.wasm"); const {instance} = await WebAssembly.instantiateStreaming(source); // 後期リンキング(Late-linking)を実装する関数 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); // 新しい関数をメインの"間接関数テーブル"に追加(grow) instance.exports.__indirect_function_table.grow( 1, newInstance.exports.my_add_func ); // リンキングされた関数のインデックスを返す return instance.exports.__indirect_function_table.length - 1; };
3.2 実行確認
このコードを実行すると、コンソール上に
5 が出力されます。
注意: この技法により生み出された WATaBoy のデモは非常に高速ですが、光感覚過敏性てんかんの患者さんにおいて発作を引き起こす可能性があります⚠️(「スタート」押下にご注意ください)。
4. 性能ベンチマーク結果
WATaBoy の JIT コンパイラ、Wasm 内インタプリタ、ネイティブインタプリタの 3 つを比較しました。 測定条件: Game Boy ROM のタイトル画面をループさせて 10 秒(壁時計時間)エミュレートし、フレーム総数をカウント。
4.1 ベンチマーク環境
| 項目 | 詳細 |
|---|---|
| OS | macOS 26.5 (25F71) |
| CPU | MacBook Air (13 インチ, M2, 2022) |
| メモリ | 16 GB |
| ブラウザ | Safari 26.5, Chrome 148.0, Firefox 150.0 |
| Rust 版 | 1.97.0-nightly (2026-05-09) |
| WATaBoy 版 | Commit c06850a |
| 検証回数 | 各構成 5 回反復(平均値) |
4.2 主要な結果:『ポケットモンスター ブルー』
- JIT-to-Wasm: ネイティブインタプリタの 約 1.2 倍 の速度に達しました。
- ネイティブマシンコードから一段間接的なレイヤがあるにもかかわらず、JIT の恩恵が確認されました。
- Wasm インタプリタ: JIT-to-Wasm よりも約 1.5 倍遅い です。
- ブラウザ依存性:
- Safari: 先行する性能を示しました。
- Firefox / Chrome: も同様の恩恵を得ていましたが、iOS が WebKit 限定であるため、Web ブラウザでの検証が重要です。
※他の ROM(『ゼルダの伝説 リンクの冒険』、『トブトブガール』)でも同様の傾向が確認されました。
5. 今後の課題と展望
5.1 WATaBoy の現状課題
- 機能: オーディオおよび GBC(カラーモード)サポートは未実装です。
- ボトルネック: PPU(ピクセルプロセッサ)のエミュレーションが実行時間の大部分を占めており、JIT ブロック内の非分岐命令の再コンパイルがまだ十分に進んでいません。
- 最適化の余地: 基本的なブロック JIT はインタプリタを上回っていますが、分岐命令を再コンパイルすることでフォールバック頻度を減らし、さらに性能向上が見込めます。
5.2 JIT-to-Wasm 技術的な制限と課題
- コード生成(Codegen)の工数:
- 既存プロジェクトは独自のツールを使用しており、DynASM や Cranelift に匹敵する使いやすさ・頑健さが不足しています。
- 将来的には、ヒューマンリーダブルな文字列を入力してコンパイル時にバイトコードを生成するような専用ツールの開発が必要です。
- 最適化の限界:
- Dolphin などが行うようなハードウェア依存の深い最適化(例:fastmem)は、Wasm ランタイムの制限により適用できません(無効なメモリアクセスは回復不可能)。
5.3 結論と意義
この手法では GameCube エミュレータをフルスピードで動作させるものではありませんが、**「インタプリタよりも速い基本的なブロック JIT が存在し、Wasm をクロスプラットフォームのターゲットとして活用できる」**ことを実証しました。
特に iOS 上での高速化において、Web ブラウザを利用した WebAssembly(JIT-to-Wasm)は大きなポテンシャルを秘めています。よりマチュアなコード生成ツールが成熟すれば、クロスプラットフォームエミュレーションの標準的な選択肢になるでしょう。
WATaBoy の全バージョンや詳細なコードは GitHub で公開されています。興味のある方は是非ご検討ください。