前書き#
最近、プログラミング言語を書くことに決めました(23 年の総括で立てた目標です)。バックエンドのコンパイルを選びましたが、最初は C バックエンドにコンパイルし、それから clang を使用してバイナリコードにコンパイルする予定でした。C のツールチェーンは非常に成熟しており、llvm ベースでさまざまなターゲットに簡単にコンパイルできるため、実行速度も保証されています(裏声でのコメント:なぜ直接 llvm ir にコンパイルしないのですか = =)。しかし、その後、別のことを考えました(〜反逆🐻〜)そして、cranelift という Rust で実装されたバックエンドを見て、それは良い選択のように思えました。cranelift は JIT コンパイルを主力としており(オブジェクトモジュールを直接生成してバイナリファイルにすることもできます)、コード生成の速度は llvm よりもはるかに速いです(もちろん、多くの最適化パスはありませんが、非常に良いパフォーマンスを維持しています)。cranelift は他の言語のバインディングを提供していないため、私は Rust を学び、そして諦めました(Rust を何度目かに学ぼうとして失敗しました、私は本当に下手です😭)。最終的に、私はひらめきました。コンパイルのバックエンドとして WebAssembly を使用することを思いつきました。そのため、WASM ランタイムを調査し、最終的にWAMRを選びました。
WAMR を選んだ理由#
- 非常に高速な実行速度(ネイティブに非常に近い)
- インタープリターモードがあります(dev モードでの高速起動の要件を満たすのにちょうど良い)
- wasi libc のサポートがあります
- 非常に小さなバイナリサイズ
いじくり始める#
最初は WAMR を試してみるつもりだったので、埋め込みを考慮せずに、公式の 2 つの CLI を使用してテストしました。公式は macOS で x86_64 のプリビルドバイナリのみを提供していましたが、私はちょうど arm チップを使用していたので、手動でコンパイルする必要がありました。まず、WAMR のソースコードをローカルにダウンロードし、iwasm と wamrc の 2 つの CLI が必要です。まず、iwasm のコンパイル方法を見てみましょう。
iwasm のコンパイル#
まず、iwasm のパスを見つけます。product-mini/platforms/darwin
フォルダに移動すると、CMakeLists.txt ファイルがあるのがわかります。興味がある方はファイルを開いてみてください。ファイル内に設定できるコンパイルオプションが表示されます。私は使用するケースに基づいて、product-mini/platforms/darwin
フォルダの下にmake.sh
ファイルを作成してコンパイルすることにしました。次に、その内容を見てみましょう。
#!/bin/sh
mkdir build && cd build
# コンパイルオプションを渡す
cmake .. -DWAMR_BUILD_TARGET=AARCH64 -DWAMR_BUILD_JIT=0
# コンパイル
make
cd ..
このシェルスクリプトでは、cmake の部分に重点があります。2 つのコンパイルオプションを渡していますので、これらのコンパイルオプションの意味を解説してみましょう。
WAMR_BUILD_TARGET=AARCH64
arm 64 ビットアーキテクチャにコンパイル
WAMR_BUILD_JIT=0
JIT 機能をコンパイルしない(実際、最初は dev モードでも JIT 機能を使用できるようにしたかったので、dev モードと最終的なビルドモードの速度差があまり大きくならないようにするためです。現在、wamr には 2 つの JIT モードがあります。1 つは Fast JIT で、もう 1 つは LLVM JIT です。LLVM JIT はサイズが大きすぎるため、最初からこの機能をコンパイルするつもりはありませんでした。なぜなら、これは dev モードでのみ使用するためであり、必要はありません。一方、Fast JIT は比較的軽量で、非常に少量のバイナリサイズの増加のみが必要であり、パフォーマンスは公式の説明によれば llvm の 50% に達するとのことです。これは dev モードにとって十分ですが、私のコンピュータではコンパイルに成功しませんでした。後で再試行します)。実行後、build フォルダに iwasm 実行ファイルが表示され、AARCH64 アーキテクチャでの純粋なインタープリタ実行のバイナリサイズはわずか 426 KB です。非常に軽量です。次に、wasm ファイルを生成してみましょう。ここでは、Rust を使用して wasm32-wasi ターゲットにコンパイルすることを選択します。まず、rustup を使用して wasm32-wasi のターゲットを追加します。
rustup target add wasm32-wasi
次に、新しい Rust プロジェクトを作成します。
cargo new --bin hello_wasm
次に、フィボナッチ数列を計算するプログラムを書いてみましょう。
use std::io;
fn fib_recursive(n: usize) -> usize {
match n {
0 | 1 => 1,
_ => fib_recursive(n - 2) + fib_recursive(n - 1),
}
}
fn main() {
println!("斐波那契数列を計算するために数字を入力してください:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("入力を読み取ることができませんでした");
let n: usize = input.trim().parse().expect("有効な数字を入力してください");
// フィボナッチ数列を計算し、時間を計測する
let start_time = std::time::Instant::now();
let result = fib_recursive(n);
let elapsed_time = start_time.elapsed();
println!("フィボナッチ数列の{}番目の値は: {}", n, result);
println!("計算時間: {:?}", elapsed_time);
}
wasi にコンパイルします。
cargo build --target wasm32-wasi --release
コンパイル後、target/wasm32-wasi/release
にコンパイルされた hello_wasm.wasm ファイルがあります。さっそく、先ほどコンパイルした iwasm を使用してこの wasm ファイルを実行してみましょう。
iwasm --interp hello_wasm.wasm
プログラムが正常に実行されることがわかります。私の Mac mini(m1 チップ)では、fib (40) を実行するのに約 3.7 秒かかりますが、Rust のネイティブプログラムの実行時間は 337 ミリ秒です。純粋なインタープリタで実行される WebAssembly の効率は、ネイティブプログラムの 1/10 程度です(実際、これはかなりの成果です。WAMR には、WebAssembly のスタックベースの仮想マシン命令を最初に内部の IR に変換してから実行する高速インタープリタの実装があります)。
wamrc のコンパイル#
次に、パフォーマンスの最適化に取り組みます。wamrc、つまり wasm ファイルを aot ファイルに変換し、先ほどコンパイルした iwasm を使用して実行し、より高速な実行速度を得ます。wamrc は llvm に依存しているため、まず llvm をコンパイルする必要があります。macOS では、llvm のコンパイルに必要な依存関係をインストールします(すでに cmake と ninja をインストールしている場合は無視してください)。
brew install cmake && brew install ninja
wamr-compiler
ディレクトリでbuild.sh
を実行します。
./build_llvm.sh
すると、エラーが発生します = =、ここで表示されるメッセージに従って修正します。おそらく、ダウンロードした llvm のバージョンが LLVM_CCACHE_BUILD オプションをサポートしていないためです。build-scripts/build_llvm.py
のパスを変更して、ccache オプションを無効にします。
LLVM_COMPILE_OPTIONS.append("-DLLVM_CCACHE_BUILD:BOOL=OFF")
修正後、llvm を再ビルドし、wamrc をコンパイルします。これは iwasm のコンパイルとあまり変わりません。公式の readme に記載されているビルド手順に従ってください。
mkdir build && cd build
cmake .. -DWAMR_BUILD_PLATFORM=darwin
make
実行後、build ディレクトリに wamrc 実行ファイルが表示されます。さっそく wamrc を使用してコンパイルしてみましょう。
./wamrc --size-level=3 -o hello_wasm.aot hello_wasm.wasm
ここでは、arm64 アーキテクチャのチップを使用しているため、--size-level = 3 のオプションを追加する必要があります。そうしないと、コンパイルできません(ファイルサイズに関係があります)。
aot で wasm を実行する#
上記でコンパイルした aot 成果物を iwasm で実行してみましょう。
./iwasm hello_wasm.aot
次に、fib (40) を呼び出してみましょう。今回は、私のマシンでは 337 ミリ秒で実行されました。これは Rust のネイティブプログラムと同じです。この単純な例では、aot と Rust のネイティブプログラムの実行速度の違いを完全に表すことはできませんが、これにより、llvm の最適化を経た WebAssembly もネイティブの実行速度に近づけることができることがわかります。
小さなエピソード#
node.js は、v8 を使用しており、非常に高度に最適化された JIT コンパイラですが、aot とは異なり、v8 はまず JavaScript をバイトコードに変換して解釈実行し、ホットな関数のみを JIT コンパイルします。また、JavaScript は動的型の言語であり、静的型の言語である WebAssembly と比較して最適化がより困難です。では、最高峰の動的言語として、上記の fib 関数を実行するのにどれくらい時間がかかるでしょうか?私のマシンでは、fib (40) を実行するのに約 959 ミリ秒かかり、Rust のネイティブプログラムの 30% に達しました。これにより、v8 の強力さが確認できます。