前書き#
最近、プログラミング言語を書くことを考えています(23 年の総括で立てた目標です)。バックエンドのコンパイラを選ぶために、C バックエンドにコンパイルしてから Clang を使用してバイナリコードにコンパイルすることを計画しました。C のツールチェーンは非常に成熟しており、LLVM をベースにしているため、さまざまなターゲットに簡単にコンパイルできます。同時に、実行速度も保証されています(裏声:なぜ直接 LLVM IR にコンパイルしないのですか?)。しかし、その後、もう少し試してみることにしました(〜反逆🐻〜)。Rust で実装された Cranelift バックエンドを調べてみたところ、良い選択肢のようです。Cranelift は JIT コンパイルを主力としています(オブジェクトモジュールを直接生成してバイナリファイルにすることもできます)。そのため、コード生成の速度は LLVM よりもはるかに速くなります(もちろん、多くの最適化パスはありませんが)。また、非常に優れたパフォーマンスも維持しています。Cranelift は他の言語のバインディングを提供していないため、Rust を学び、その後放棄しました(Rust を何度も学んで失敗しました、私はとても下手です😭)。最終的に、ひらめきました。WebAssembly をコンパイルのバックエンドとして使用することにしました。そのため、WASM ランタイムを調査し、最終的にWAMRを選びました。
WAMR を選んだ理由#
- 非常に高速な実行(ネイティブに非常に近いパフォーマンス)
- インタープリターモードを備えています(開発モードでの高速起動の要件を満たすため)
- 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 モードにとって十分ですが、私のコンピュータではコンパイルに成功しませんでした。後でもう一度試してみます)。ビルドフォルダには、AARCH64 アーキテクチャで純粋なインタープリター実行のバイナリファイルが 426 KB しかないことがわかります。非常に軽量です。次に、WebAssembly ファイルを生成してみましょう。ここでは、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!("Please enter a number to calculate the Fibonacci sequence:");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("Failed to read input");
let n: usize = input.trim().parse().expect("Please enter a valid number");
// Calculate the Fibonacci sequence and measure the time
let start_time = std::time::Instant::now();
let result = fib_recursive(n);
let elapsed_time = start_time.elapsed();
println!("The value of the {}th item in the Fibonacci sequence is: {}", n, result);
println!("Calculation time: {:?}", 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 は、非常に高度に最適化された JIT コンパイラである V8 を使用していますが、aot とは異なり、V8 は JavaScript をバイトコードに変換してからインタープリターで実行し、ホットな関数のみを JIT コンパイルします。また、JavaScript は静的型の言語でないため、静的型の言語である WebAssembly よりも最適化が難しいです。では、最高の動的言語として、上記の fib 関数を実行するのにどれくらい時間がかかるでしょうか?私のマシンでは、fib (40) を実行するのに約 959 ミリ秒かかり、Rust のネイティブプログラムの 30% に達しました。これは V8 が本当に強力であることを示しています。