プログラミング
WATaBoy: Game Boy命令をWASMにJITコンパイルすることでネイティブインタープリターを凌駕
WATaBoy: JIT-Ing Game Boy Instructions to WASM Beats a Native Interpreter (humphri.es)
要約
このブログ記事は、Just-in-Time (JIT) コンパイルの概念を活用して、Game BoyエミュレータをWebAssembly (WASM) にJITコンパイルし、そのパフォーマンスをネイティブインタープリターと比較したものです。iOSのJIT制限がある中で、WebブラウザのWASMコンパイル機能を迂回して、より高速なエミュレーションを実現する可能性を探っています。特にRustでのWasmコード生成と遅延リンキングの実装に焦点を当て、その性能を検証しています。
全文翻訳
背景 このテキストは、読者がJust-in-Timeコンパイルの概念に精通していることを前提としています。DolphinがiOSで利用できないのは、iOSでJITコンパイルができないためです。これは、OatmealDomeのブログ記事「Why Dolphin Isn’t Coming to the App Store」の簡潔な要約です。これを読んで以来、DolphinのようなCPU負荷の高いエミュレータをiOSで動作させるには何が必要なのか、ずっと考えていました。iPhoneのCPUがインタープリターでDolphinを実行できるほど高速になるまで、数年待つしかないのでしょうか?
しかし、AppleはJITの制限に一つの例外を設けています。それはウェブブラウザです。WebKitのJSエンジンであるJavaScriptCoreは、高パフォーマンス層でJITコンパイルを使用しています。そのため、JS関数が十分な回数呼び出されると、最終的に最適化され、ネイティブマシンコードにコンパイルされます。WebAssemblyも同様です。では、これを便乗して利用できないでしょうか?直接ネイティブマシンコードを生成する代わりに、WebAssemblyバイトコードを生成し、それがWebブラウザによって最終的にネイティブマシンコードにコンパイルされるようにするのです。
Andy Wingoのブログ記事「just-in-time code generation within webassembly」を読んで、そのようなことが可能だと確信しました。実際、すでにいくつかのプロジェクト、具体的にはThe Jiterpreterやv86がこの技術を使用していますが、執筆時点ではゲーム機のエミュレータでこれを使用しているものはなく、ネイティブで動作するインタープリターと比較して高速かどうかを比較した人もいませんでした。そこで、学部最終学年のプロジェクトとして、まずインタープリターを使用し、次にJIT-to-Wasmを使用するGame Boyエミュレータを構築することにしました。このプロジェクトは、主に各アプローチのパフォーマンスを比較するための概念実証とベンチマークとして機能します。このブログ記事の残りの部分では、JSエンジン自体が行うこと(Wasmをマシンコードに再コンパイルすること)との混同を避けるため、これを「Wasm JIT」ではなく「JIT-to-Wasm」と呼びます。
Game BoyエミュレータであるWATaBoyのスクリーンショットで、SM83をWasmにコンパイルします。エミュレーションについて少し知っている読者は、JITコンパイルがGame Boyエミュレータにどう役立つのか、と眉をひそめたでしょう。幸いなことに、GameRoyのブログ記事では、サイクル精度を保ちながらそれが可能である方法が正確に説明されています。
* JITブロックが中断される可能性のある場合に、割り込みが発生するタイミングを予測する
* インタープリターにフォールバックする
* MMIOを介してアクセスされるCPU以外のGame Boyコンポーネントを遅延評価する
GameRoyのJITはx86のみをターゲットとしていますが、その最適化技術のほとんどは、私たちのJIT-to-Wasmにも適用されます。Game Boyエミュレーションの詳細に興味がある場合は、ぜひチェックしてください。大きなインスピレーションになりました。それでも、Game Boyエミュレータは、例えば第6世代のコンソールほどJITコンパイルの恩恵を受けません。しかし、作成ははるかに速く、最終学年のプロジェクトの範囲に収まりました。
実装 さて、このブログ記事の範囲を絞るために、WATaBoyの中で私が他のどこにもガイドを見つけることができなかった、最も広く適用可能な部分、つまりRust内からのWasmコード生成と遅延リンキングについて説明します。WATaBoyを興味深いものにしている点はたくさんあります。特にGame Boyエミュレーションの観点からは(例:SIMDタイルレンダリングなど)ですが、それらの実装の詳細は個別の記事に値します(もちろん、WATaBoyのソースを読んでも構いません)。興味がなければ、結果にスキップしてください。
通常、RustとJavaScriptの間のグルーコードを生成するには、wasm-bindgenやwasm-packのようなツールを使用します。しかし、これらのツールは、Wasmを低レベルで扱う際に人間工学的な問題を引き起こします。代わりに、「Rust to WebAssembly the hard way」で説明されているアプローチと同様の方法を使用します。これは、JavaScriptオブジェクトの代わりにポインタとバッファ長を使用して、C ABIを介してRust-JS境界でデータを渡すことを意味します。注意点として、後で少しだけインラインWasmを使用するため、Nightly Rustが必要です。なので、以下を実行してください。
`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` クレートが唯一の依存関係になります。それを使用すると、一種のビルダーパターンを使用してWasm命令のバイトを出力できます。私たちのJITのユースケースには設計されていなかったため、人間工学的な問題とわずかなボイラープレートがありますが、手動で生のバイトの配列を記述するよりも間違いなく優れています。:)
`[package]
name = "jit-to-wasm"
version = "0.1.0"
edition = "2024"
[lib]
# Required to produce a .wasm file.
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();
// Encode the type section for the add function.
// Parameters: 32-bit int left, 32-bit int right.
// Returns: 32-bit result.
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);
// Encode the function section.
let mut functions = FunctionSection::new();
let type_index = 0;
functions.function(type_index);
module.section(&functions);
// Encode the export section.
let mut exports = ExportSection::new();
exports.export("my_add_func", ExportKind::Func, 0);
module.section(&exports);
// Encode the code section.
let mut codes = CodeSection::new();
let locals = vec![];
let mut my_add_func = Function::new(locals);
my_add_func
.instructions()
// Get the first 32-bit int onto the stack (left).
.local_get(0)
// Get the second 32-bit int onto the stack (right).
.local_get(1)
// Add the two ints together.
.i32_add()
.end();
codes.function(&my_add_func);
module.section(&codes);
// Extract the encoded Wasm bytes for this module.
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();
// Execute add ...somehow???
}`
コンパイルとリンク Wingoのブログ記事を思い出してください。Wasmはフォン・ノイマンアーキテクチャではなく、ハーバードアーキテクチャです。実用的な観点から言えば、プログラムによって生成されたバイトコードを直接実行することはできません。WebAssemblyの場合、新しいWasmバイトコードをコンパイル、インスタンス化、およびリンクするには、エンベッダー(通常はJavaScript)に依頼する必要があります。jit-interface提案は、`func.new`命令を使用してWasmでこれを直接行う方法を提供するかもしれませんが、今のところ、JavaScriptと通信する必要があります。
まず、同期コンパイルインターフェースを使用してバイトコードをコンパイルおよびインスタンス化します。(コンパイルとインスタンス化)次に、生成されたモジュールから関数をメインモジュールの間接関数テーブルに追加し、後で呼び出せるようにテーブル内のそのインデックスを追跡します。(リンク)最後に、間接関数テーブルのn番目の関数を呼び出す`call_indirect`命令を使用して、実際に関数を実行できます。(ディスパッチ)
バイトコードのバッファをコンパイル、インスタンス化、およびリンクする「linkNewModule」という関数をすでにインポートしていると想像してみましょう。実際のものは後でJavaScriptで実装します。
`#[link(wasm_import_module = "env")]
unsafe extern "C" {
// Returns the new function's index in the table.
#[link_name = "linkNewModule"]
fn link_new_module(buffer: *const u8, len: usize) -> i32;
}`
次に、間接関数テーブルのn番目の関数を呼び出すディスパッチ関数を実装します。実際に行う必要があるのは、`call_indirect` Wasm命令を実行することだけです。通常、このようなことをしたい場合は、`std::arch`の組み込み関数に手を伸ばすでしょうが、`call_indirect`にはありません。そこで、ごくわずかなインラインWebAssemblyを使用する必要があります。これは不安定な機能です。