最速のWebAssemblyインタープリタのエンジニアリング
Engineering of the fastest WebAssembly interpreters (wasmi-labs.github.io)
要約
Wasmi 2.0は、前バージョンと比較して実行速度が約2.2倍向上した新しいWebAssemblyインタープリタです。この高速化は、直接スレッドコードなどの新しい命令ディスパッチモードの導入や、実行ハンドラシグネチャの最適化によって達成されました。Wasmi 2.0は、様々なハードウェア環境で他の主要なインタープリタと比較しても最速クラスの性能を示しており、IoTデバイスからクラウドホストまで幅広い用途に適しています。
全文翻訳
Wasmi 2.0 - 最速のWasmインタプリタのエンジニアリング
前回のWasmi 1.0に関する投稿で、将来のWasmiバージョンに向けた根本的なエンジンオーバーホールを約束しました。その未来が今、ここにあります![1]
Wasmiは、効率的で機能豊富なWebAssembly (Wasm)インタプリタです。IoTデバイス、プラグインシステム(Typst、Zellij、Josh)、クラウドホスト、スマートコントラクト(Soroban、Ripple)、さらには軽量なゲームコンソール(Firefly Zero)などにも最適な選択肢です。
詳細に入る前に、2024年10月からWasmiプロジェクトをスポンサーしてくださっているStellar Development Foundation (SDF)に多大な感謝を捧げます。彼らのスポンサーシップなしには、Wasmiプロジェクトは今日の姿にはなり得ませんでした。また、記事の校正と多くの改善提案をしてくれたFelix Kutznerにも特別な感謝を述べます。
Wasmi 2.0 リリース
8ヶ月間の集中的な作業を経て、Wasmi 2.0が完成し、利用可能になったことを本日発表できることを嬉しく思います。
このリリースは実行パフォーマンスに重点を置いており、Wasmi 2.0はApple M2 Pro上のwasmi-benchmarksスイート全体での幾何平均で、Wasmi 1.0よりも約2.2倍高速に動作します。
Wasmi 2.0は、validateクレート機能のような新しいノブも搭載しており、バイナリ成果物のサイズを大幅に削減します[2]。安定した燃料計測[3]、WebAssemblyの決定論的プロファイルのサポート、そして改善されたWasmi CLIツールといった、ユーザーから要望のあった機能もこのリリースに含まれています。
いつでもGitHubでのスターで私を喜ばせることができます!
Wasmi 2.0 の到達点
Wasmi 1.0より2.2倍高速というのは素晴らしいですが、Wasmi 2.0は競合と比較してどうでしょうか?
このため、私はWasmi 2.0を、最も高速なポータブルWasmインタプリタのいくつかに対してベンチマークしました:[4]
注意: Wasmi 2.0は上記のすべてのインタプリタからインスピレーションを受けています!
ベンチマークはwasmi-benchmarksプロジェクトを使用して実施され、ご自身のマシンで簡単に再現できるようになっています。
インタプリタの好みを可視化するために、3つの異なるハードウェアセットアップでベンチマークを実行しました。
Apple M2 Pro
[画像]
AMD EPYC 7763
[画像]
Intel Xeon Platinum 8370C
[画像]
これらはwasmi-benchmarksプロジェクトがサポートする全ベンチマークとランタイムのほんの一部に過ぎませんが、良い概要を提供します。
幾何平均
以下の2つのプロットは、上記のWasmランタイムにおけるwasmi-benchmarksスイートのすべてのexecuteおよびすべてのstartupベンチマークにおける幾何平均を示しています。
[画像]
[画像]
注意: Wasmtime Pulleyが非常に外れ値であるため、
startupには対数スケールを使用する必要がありました。[5]
Wasmi 2.0の実行パフォーマンスへの注力にもかかわらず、その起動パフォーマンスは依然として際立っており、以前のバージョンとほぼ同等です[6]。
結論: ベンチマーク
Wasmi 2.0が最速のポータブルWasmインタプリタのカテゴリに明確に属すると言っても過言ではありません。
フォローアップ記事では、wasmi-benchmarksスイートのすべての結果と発見を発表し、サポートされている多数のWasmランタイムをそれぞれにふさわしいスポットライトに当てます。
Wasmi 2.0 はなぜこれほど高速になったのか?
Wasm3とStitchは、内部構造においてWasmi 2.0と多くの類似点を共有しています。このセクションでは、Wasmi 2.0に採用されたアイデアを詳述するとともに、関連する箇所ではそれらとの類似点と意図的な違いについても議論します。
注意: このセクションでは、Wasmとインタプリタに関する基本的な理解を前提としています。
新しい命令ディスパッチモード
Wasmi 1.0のブログ投稿で約束した通り、Wasmi 2.0には4つの異なる命令ディスパッチモードが導入されました。
| モード |
| --- | --- |
| Direct-Threaded Code |
| Indirect-Threaded Code |
| Switch-Loop |
| Call-Loop |
Wasmiユーザーは以下を使用すべきです。
- Direct-Threaded Code: インタプリタのパフォーマンスを最大化したい場合。
- Indirect-Threaded Code: インタプリタのパフォーマンスとメモリ使用量の良いバランスを取りたい場合。
- Switch-Loop: テールコールをサポートしないプラットフォームで実行する場合。
Wasmi 2.0はauto-dispatchクレート機能を搭載しており、可能な場合は常にスレッドコードベースの設定を自動的に使用します[7]。
命令ディスパッチモードのパフォーマンス
[画像]
注意: Direct-Threaded CodeのCoreMark結果は、別の実行であり
wasm-coremark-rsプロジェクトを使用したため、上記の実行結果と完全に一致しません。
これらの極端なパフォーマンスの違いにもかかわらず、これらの命令ディスパッチモードはすべて、内部で同じインタプリタ実行ロジックとアーキテクチャを共有しています。
Wasmiでの命令ディスパッチ選択がどのように機能するか詳細に興味がある場合は、コードをWasmi Dispatch Selectionで見つけることができます。
実行ハンドラシグネチャ
実行前に、WasmiはWasmバイトコードをWasmi IRに変換します。
各Wasmi IR命令には独自の命令ハンドラ(または実行ハンドラ)があり、命令がどのように実行されるかを定義します。
Wasmi 2.0では、すべての命令ハンドラが同じシグネチャを共有します。
fn(
store: &mut PrunedStore, // 実行に関連付けられた `Store<T>` への参照。
ip: Ip, // 命令ポインタ。
sp: Sp, // スタックポインタ。
mem0: Mem0Ptr, // デフォルトの線形メモリのデータへのポインタ: `(memory 0)`
mem0_len: Mem0Len, // デフォルトの線形メモリのバイト数。
instance: Inst, // 現在実行中の関数によって使用されるWasmインスタンスへのポインタ。
ireg: Ireg, // 整数値および参照値用のアクセプタレジスタ。
freg32: Freg32, // `f32` 値用のアクセプタレジスタ。
freg64: Freg64, // `f64` 値用のアクセプタレジスタ。
) -> Done; // トラップまたは成功した停止を通知するために使用される状態。
store引数は基本的にT型でプルーニングされたStore<T>です。命令ハンドラはジェネリックにできないため、これは重要です。storeは燃料計測、ホストコール、memory.growおよびtable.grow操作に使用されます。ip引数は命令ポインタであり、エンコードされた命令ストリームのどこにいるか、どの命令をデコードして実行する必要があるかをエグゼキュータに伝えます。sp引数は、値スタック内の現在実行中の関数の位置です。mem0およびmem0_len引数は、デフォルトメモリ(memory 0)への最適化されたアクセスに使用されます。これは、Wasmのmulti-memory提案を使用している場合でも、Wasmでは非常に一般的です。instance引数は、グローバル、関数、テーブル、メモリ、データ、要素セグメントなどのWasmインスタンス関連オブジェクトをロードするために使用されます。この投稿でさらに詳しく説明します。ireg、freg32、freg64引数は、命令間の結果を効率的に格納するために使用されるアクセプタレジスタです。この投稿でさらに詳しく説明します。Doneの結果は、実行が停止した理由をエグゼキュータに伝える単なるビットパターンです。詳細情報は後で取得できるようにstoreを通じて通信されます。
問題: 呼び出し規約
Wasmiの命令ハンドラの9つの引数のうち7つは、汎用レジスタ(GPR)で値を渡す必要があります。具体的にはstore、ip、sp、mem0、mem0_len、instance、iregです。
しかし、sysv64のような一般的な呼び出し規約は、整数引数に対して最大6つのGPRしか提供しません。7番目の整数引数は、ディスパッチごとにスタックにスピルする必要があるため、パフォーマンスを低下させます。StitchとWasm3は、それぞれ4つと6つのGPRしか使用しないことでこの問題を回避しています。
簡単な解決策は、必要に応じてWasmiのGPR引数の1つを浮動小数点値に変換することです。instance引数は、いずれにしても比較的コストの高い操作にしか使用されないため、これに選ばれました。
ベンチマークによると、整数から浮動小数点へのレジスタドメインの移動は大きな問題ではありません。
注意: 現在不安定な
preserve_noneABIは、将来的に安定し、より多くのプラットフォームで利用可能になった場合に、この状況を改善できる可能性があります。
アキュムレータレジスタ
Wasmi 1.0 の動作
Wasmi 1.0は、オペランドとIR命令の結果のために、スタックオフセット(スタックスロット)を広範囲に使用していました。
res = lhs + rhsを計算する単純化されたi64.add命令ハンドラを以下に示します。ここでresとlhsはスタックスロット、rhsは即値i64値です。
fn i64_add(ip: Ip, sp: Sp, ..) -> Done {
let res: Slot = decode_slot(ip);
let lhs: Slot = decode_slot(ip);
let rhs: i64 = decode_i64(ip);
let lhs: i64 = lhs.load(sp);
let sum: i64 = lhs + rhs;
res.store(sp, sum);
ip.offset(encode_size::<i64_add>);
next!(ip, sp, ..)
}
ipから結果Slotをデコードします。ipから左辺オペランドlhsSlotをデコードします。ipから右辺オペランドrhs: i64をデコードします。lhsから値をロードします。(sp[lhs])- 合計
sp[lhs] + rhsを計算します。 - 合計を
sp[result]に格納します。 ipを次の命令ハンドラにオフセットします。- 次の命令ハンドラを実行します。
Wasmi 2.0 の動作
Wasmi 2.0は、3つの新しいアキュムレータレジスタireg、freg32、freg64を導入しました。これにより、Wasmi 2.0は命令オペランドと結果を実際のハードウェアレジスタからロードおよびストアできるようになります。
res = lhs + rhsを計算する単純化されたi64.addの例で、resとlhsがiregアキュムレータを参照し、rhsがi64即値である場合、次のようになります。
fn i64_add(ip: Ip, sp: Sp, ireg: i64, ..) -> Done {
let rhs: i64 = decode_i64(ip);
ireg = ireg + rhs;
ip.offset(encode_size::<i64_add>);
next!(ip, ireg, ..)
}
ipから右辺オペランドrhs: i64をデコードします。- 合計
ireg + rhsを計算します。 - 合計を
iregに格納します。 ipを次の命令ハンドラにオフセットします。- 次の命令ハンドラを実行します。
これは、アキュムレータレジスタが常に暗黙的であり、コストのかかるデコード、ロード、ストアが不要なため、著しくシンプルで効率的です。
これはaarch64アセンブリにコンパイルされたときの様子です。x1 = ip、x6 = iregとします。
i64_add_rri:
ldr x7, [x1, #16]! ; bump ip by 16 bytes and load next handler
ldur x8, [x1, #-8] ; fetch rhs immediate operand from ip
add x6, x8, x6 ; ireg = ireg + rhs
br x7 ; tail-call next handler
コピー命令
Wasmi 2.0の命令は通常、その結果を暗黙のアキュムレータレジスタに格納します。
この設計の欠点は、古い設計では必要なかったコピー命令が必要になることです。
例: local.set & local.tee
local.get 0 ;; push (local 0)
i32.const 10 ;; push 10
i32.add ;; pop 2 operands, push their sum
local.set 1 ;; pop sum, store into (local 1)
このWasmシーケンスは(local 0) + 10を加算し、結果を(local 1)に格納します。Wasmi 1.0では、これを単一のWasmi 1.0 IR命令で実行できました。
i32_add_ssi 1 0 10
注意: 接尾辞
sはスタックスロット、rはアキュムレータレジスタ、iは即値を表します。
Wasmi 2.0では、同じジョブに2つの命令が必要です。
i32_add_rsi 0 10 ;; ireg = (local 0) + 10
u64_copy_sr 1 ;; (local 1) = ireg
例: レジスタの保持
local.get 0 ;; push (local 0)
i32.const 10 ;; push 10
i32.add ;; pop 2 operands, push their sum
local.get 1 ;; push (local 1) on top of the pending sum
i32.const 20 ;; push 20
i32.mul ;; pop 2 operands, push their product
Wasmi 2.0では、これを次のようなWasmi 2.0 IRに変換する必要があります。
i32_add_rsi 0 10 ;; ireg = (local 0) + 10
u64_copy_sr A ;; (slot A) = ireg
i32.mul_rsi 1 20 ;; ireg = (local 1) * 20
u64_copy_sr A命令は、次の命令でiregを上書きするが実際には使用しないため、前のiregの値を保持する必要があるために必要です。
ソリューション1: より効率的なコピー
示されているように、Wasmi 2.0はこの設計により、はるかに多くのコピー命令を必要とします。それらの数を減らすための効果的な方法がいくつかあり、残りのコピーについては、いくつかのパターンが現れました。
Wasmi 2.0は、一般的な状況のために最適化されたIR命令を導入しました。
u64_copy_sNr:iregを固定(local N)(N = 0..10)にコピーします。f32_copy_sNr:freg32を固定(local N)(N = 0..10)にコピーします。f64_copy_sNr:freg64を固定(local N)(N = 0..10)にコピーします。u64_copy_sNsM:(local M)を(local N)(N,M = 0..5、N != M)にコピーします。freg32およびfreg64のバリアントは不要です。スタックスロットは常に汎用64ビットパターンとして扱われるためです。
ソリューション2: オペコードの融合
Wasmは、addとload命令の後にすぐにlocal.setまたはlocal.teeが続くというパターンを多く生成します。
これは非常に一般的であるため、アキュムレータレジスタだけでなくスタックスロットにも結果を格納する特別な融合バリアントを導入する価値がありました。これにより、Wasmi 2.0はこれらのユースケースを単一のIR命令で表現できます。
制御フロー境界を越えるアキュムレータ
制御フローのために、WebAssemblyはblock、loop、ifフレームを使用します。
br(ブランチ)、br_if(条件付きブランチ)、またはbr_table(ブランチテーブル)命令を使用して、blockまたはifの末尾にジャンプするか、loopを続行することができます。
制御フローはスタックとして編成されているため、ブランチはジャンプ先のスタック深度を使用します。深度0は直接の親のラベルにジャンプします。
さらに、制御フローにはパラメータと結果があり、Wasmi 2.0は可能な限りアキュムレータレジスタをこれらの境界を越えて伝達します。
- 例えば、
blockが結果タイプ(i32 f32)を持つ場合、Wasmi 2.0はiregとfreg32の両方を使用して結果を返します。 blockが(i32, i32, i32)の結果タイプを持つ場合、Wasmi 2.0は最後の結果のみをiregに入れ、他の結果はスタックスロットに返します。- 技術的な理由から、アキュムレータレジスタは結果の末尾にのみ使用されます。例えば、結果が
(f32 i32 i32)のblockは、最後のi32のみをiregに入れ、他のすべてをスタックスロットに入れます。
同じルールがifの結果とloopのパラメータに適用されます。
loopの場合、これにより誘導変数がアキュムレータレジスタに留まる可能性があります。この例は、wasmi-benchmarksのexecute/counter-paramテストケースで見ることができます。
注意: コールにも同じルールを適用する実験を行いましたが、残念ながらパフォーマンスの低下を招いたため、マージされませんでした。
インスタンスオブジェクトアクセス
Wasmi 1.0 の動作
Wasmi 1.0は、Wasmモジュールインスタンスの内部オブジェクト表現をモデル化するための単純な方法を使用しています。インスタンス(またはInstanceEntity)は、インスタンスオブジェクトのタイプごとに1つのヒープ割り当てを使用します。例えば、メモリ、テーブル、関数、グローバル、データ、要素セグメントなどです。InstanceEntityはハンドルのみを保持し、ストアから取得して、グローバルの値とタイプなどの実際のオブジェクト内部を取得する必要があります。
[画像]
global.get g命令はWasmi 1.0では3つのことを行います:instance.globalsを読み込んでボックス化されたスライスを取得し、インデックスgでハンドルhをロードし、次にストアのグローバルテーブルでhを解決します。各ロードは前のロードに依存しており、非常にコストがかかります。
この手順は非常に遅いため、Wasmi 1.0は、CコードをWasmにコンパイルした場合のシャドウススタックへのポインタとして一般的に使用される、Wasmインデックス0のグローバルへのアクセスという一般的なケースを高速化するために、(global 0)の特別な処理を提供しています。
Wasmi 2.0 の動作
Wasmi 2.0は、同じWasmモジュールのすべてのWasmインスタンスが同じオブジェクトレイアウトを共有するという事実を認識しています。インスタンスオブジェクトのアドレスは、Wasmモジュールのプロパティです。
[画像]
すべてのWasmi 2.0命令ハンドラの前に述べたinstance: Instパラメータは、動的にサイズ変更可能な型であるInstanceEntityへの薄いポインタです。
InstanceEntityは、固定サイズのInstanceEntityHeaderと、動的にサイズ変更可能なhandlesバッファで構成されます。handlesバッファには、単一の連続した割り当て内にすべてのインスタンスオブジェクトが含まれています。
順序は、memories、globals、tables、funcs、elems、datasです。
memoriesが最初にあるため、一般的に使用されるデフォルトメモリ(memory 0)がアドレス0に留まります。さらに、これによりWasmiはメモリアドレスを16ビット値として定義できます。datasは最後になければなりません。なぜなら、data countセクションはWasmモジュール作成時に必ずしも利用可能とは限らないため、データセグメントがいくつ存在するかは不明です。InstanceEntityHeaderには、一般的に使用されるデフォルトテーブル(table 0)への高速アクセスを可能にするtable0フィールドが含まれています(例:call_indirect用)。[9]- 残りの
globals、tables、funcs、elems領域の順序は、Wasm実行におけるこれらのオブジェクトアクセスの一般的な頻度に基づいて選択されました。
handlesバッファには、handleと、ストアが所有する実際のインスタンスオブジェクトへのポインタであるentityキャッシュが含まれています[10]。これらのentityポインタは、実行がそれらに依存できるように、Wasmインスタンス化中に初期化されます。
このため、ストアもわずかに再設計する必要がありました。以前はArenaだったコンテナが、所有するオブジェクトの安定したアドレスを保証するようになり、StableArena型が作成されました。
これらのすべてにより、Wasmi 2.0 IRはインスタンスオブジェクトにアクセスするためにWasmインデックスではなくインスタンスアドレスを使用するようになり、インスタンスオブジェクトへのアクセスはinstanceからのポインタオフセット1つだけで済むため、非常に高速になります。
インスタンスアクセス: Wasm3 & Stitch との比較
Wasm3とStitchの両方がインスタンス関連のバイトコードを使用しています。これにより、各インスタンスはインスタンスオブジェクトへのポインタをバイトコードに埋め込むことができますが、同じWasmモジュールの各インスタンスが独自のバイトコードを格納する必要があります。
Wasmiはモジュール関連のバイトコードを使用します。これは、同じWasmモジュールのすべてのWasmインスタンスが同じ基盤となるWasmi IRバイトコードを共有することを意味し、メモリ消費を大幅に改善します。
InstanceEntityの再設計のおかげで、Wasmi 2.0は、インスタンスオブジェクトポインタをバイトコードに埋め込むことができないにもかかわらず、インスタンスオブジェクトアクセスのパフォーマンスにおいてWasm3およびStitchレベルを達成しています。
wasmi-benchmarksのcounter-globalベンチマークは、グローバル変数のみを使用して多数をゼロまでカウントします。これはWasmインタプリタのインスタンスオブジェクトアクセスにかなりの負荷をかけます[11]。
[画像]
新しいWasmi 2.0の設計により、(global 0)キャッシュの必要性がなくなり、両方のケースで優れたパフォーマンスを発揮します。Wasmi 1.0は、(global 0)キャッシュが使用されなくなったため、(global 1)で大幅に低下します。
[画像]
上記のインスタンスレイアウト最適化により、Wasmi 2.0のglobal_get_u64_r命令ハンドラは次のようになります。
global_get_u64_r:
ldr x7, [x1, #16]! ; bump ip by 16 bytes and load next handler
ldur w8, [x1, #-8] ; fetch global address operand from ip
add x8, x5, x8, lsl #4 ; compute instance[address]
ldr x8, [x8, #64] ; offset instance[address] by constant handles offset
ldr x6, [x8] ; load raw global value into ireg
br x7 ; tail-call next handler
ロックフリー CodeMap
WasmiのCodeMapはWasmiのEngineの一部であり、すべてのWasmi IR関数本体を格納します。一方、残りの関数情報(FuncEntity)はWasmiストアにあります。
インスタンス関連のバイトコードのため、Wasm3とStitchの両方はWasm関数を別のタイプのインスタンスオブジェクトとして扱うことができ、エンコードされたIR命令ストリームに直接Wasm関数のポインタを埋め込むことができます。そして、それがコールを高速化するために行っていることです。
Wasmi 1.0 の動作
CodeMap全体が、mutexで保護されたVecのようなアリーナデータ構造でした。そのため、すべての内部Wasmコールはmutexを取得し、Vecのようなアリーナにインデックスを付ける必要がありました。言うまでもなく、これは非常にコストがかかり、非効率的な手順でした。
pub struct CodeMap {
funcs: Mutex<Arena<EngineFunc, FuncEntity>>,
features: WasmFeatures,
}
Wasmi 2.0 の動作
すべてのインスタンスが1つのWasmi IR翻訳を共有するため、関数に対する単一のエンジンレベルのアドレスは、同じWasmモジュールのすべてのインスタンスに対して有効です。
StitchやWasm3と同様に、Wasmiはしたがって、Wasmi IR関数本体へのポインタをバイトコードに焼き付けることができます。
焼き付けられたポインタは、それが指す関数が移動しない限り有効です。しかし、CodeMapは、新しく翻訳されたWasmモジュールが関数を追加するため、成長し続けます。Wasmi 1.0で使用されていたVecのようなアリーナは、成長するにつれて再割り当てされ、すべてのエントリを移動させ、それまでに焼き付けられたすべてのポインタを無効にします。さらに、エンジンはストア間で共有されるため、他のスレッドがすでに実行中にこの成長が発生する可能性があります。
Wasmi 2.0はしたがって、エンジン寿命中に再割り当てや移動が発生しない、追記専用バケットに格納します。CodeMapへの新しい関数の割り当て(例: Module::new経由)はシリアルですが、割り当てられた関数へのアクセスはロックフリーで、同期オーバーヘッドが最小限で並行して行われます。
[画像]
各FuncEntryはその独自の原子状態を保持しているため、コールのホットパスはFuncEntryがコンパイル済みかどうかを確認し、その関数本体の内部を返します。
これにより、FuncEntryの遅延コンパイルが可能になります。これはコールドパスと見なされ、FuncEntryあたり最大1回しか成功しません。
Wasmi 2.0のcall_internal命令ハンドラは、そのFuncEntryアドレス(ポインタ)がオペランドの1つとしてバイトコードに直接エンコードされているため、StitchやWasm3と同様に、ルックアップをゼロで行います。
コール: Wasm3 & Stitch との比較
wasmi-benchmarksのfibonacci-recベンチマークは、WasmからWasmへのコールを強調します。
[画像]
新しいCodeMap設計は、コールについても同じギャップを埋めます。Wasmi 2.0は、モジュール関連のバイトコードと共有アトミックロードの必要性にもかかわらず、StitchおよびWasm3に追いついています。WasmiがStitchおよびWasm3を上回る理由は、他の技術的な違いによるものです。
- StitchとWasm3は、コールスタックとバリュースタックをマージしており、より多くのコピーオーバーヘッドが発生しますが、Wasmiはそれを回避するために2つの異なるスタックを使用します。
- さらに、Wasm3は関数ごとに定数プールを使用して即値オペランドの必要性を回避しており、関数コールあたりのコピーオーバーヘッドがさらに増加します。
固定64ビットセル
Wasmiエグゼキュータは、スタック上の値をいわゆるスタックスロットまたはセルに編成します。Wasmi 1.0では、セルは64ビット幅、またはsimdクレート機能が有効な場合は128ビット幅です。デフォルトではsimd機能は無効ですが、Wasm simd提案のサポートが必要なユーザーはそれを有効にします。
セルの幅を64ビットから128ビットに広げることは、メモリ消費量の増加を引き起こすだけでなく、メモリトラフィックの増加、キャッシュ利用率の低下、コール境界でのコピー幅の増加により、パフォーマンスを約5〜10%低下させます。
一方、Wasmi 2.0はセルの幅を常に64ビットに固定します。simd値の場合は、単に2つの隣接するセルを使用します。どの値にどのセルが割り当てられるかを決定する作業はトランスレータで行われるため、エグゼキュータでは何も行う必要はありません。
Wasm simd命令自体は遅くありません。
- Wasmi 1.0はすでに128ビットセルを2つの64ビット半分として格納していたため、どちらの場合も同じ数の64ビットワードが移動します。
- Wasmi 2.0は、スタック上のすべての非
v128値にその幅を課すことを停止するだけです。
Wasmi 2.0でsimdを有効にしても、メモリ消費量が増加したり、パフォーマンスが低下したりすることはありません[12]。
[画像]
上記の図は、simd機能が有効(+simd)および無効の場合のWasmi 1.0とWasmi 2.0のCoreMark結果への影響を示しています。
ご覧のとおり、Wasmi 1.0は約8%低下しますが、simdが有効なWasmi 2.0は、ノイズレベル内でsimdが無効なWasmi 2.0と同じくらい高速なままです。
意図しないRustの最適化解除
wasmi-benchmarksで作業し、他のWasmランタイムをベンチマークしている間に、Stitchのパフォーマンスが以前の測定よりも大幅に低下していることに気づきました。
私のApple M2 ProでのCoreMarkスコアは、3000点以上から約2200点へと約30%低下しました。この低下は、Rust 1.91と1.92の間で発生しました。
さらに調査した結果、原因が判明しました。DestinationPropagationというMIR最適化で、Rust 1.92でデフォルトで有効になりました。このパスは、同じ値を持つMIRローカルをマージします。その結果、条件付きブランチハンドラの2つのディスパッチパスが1つに収束します。cselが単一のブランチサイトにフィードします。CPUのブランチ予測子は、履歴が混在する単一のエントリしか認識しなくなり、そのジョブが複雑になります。
Stitchに修正が適用された後、そのCoreMarkスコアは再び3000点以上に回復しました。成功です!
cargo-show-asmを使用して、Wasmi 2.0自体のi32.lt命令ハンドラを確認したところ…なんと!同じ根本的な問題に悩まされていました[13]。
branch_i32_lt_ri:
ldp w8, w9, [x1, #8] ; fetch branch offset and rhs immediate from ip
sxtw x8, w8 ; sign-extend the branch offset
add x10, x1, #16 ; compute the fall-through ip
add x8, x1, x8 ; compute the branch target ip
cmp w9, w6 ; branch is taken if ireg < rhs
csel x1, x8, x10, gt ; csel picks the target
ldr x7, [x1] ; load the handler at the chosen ip
br x7 ; unified branch site to the chosen target
Wasmi 2.0に修正を適用した後、そのCoreMarkスコアは約2800から4200以上に上昇しました。これは、この単一の修正による約50%の改善であり、Wasmi 2.0にとって最も重要な「最適化」となりました。
著しく高速になったi32.ltのアセンブリは次のようになります。
branch_i32_lt_ri:
ldr w8, [x1, #12] ; fetch rhs immediate operand from ip
cmp w8, w6 ; branch is taken if ireg < rhs
b.le LBB1242_2 ; not taken: continue with the next instruction
ldrsw x8, [x1, #8] ; fetch the sign-extended branch offset from ip
add x1, x1, x8 ; ip = branch target
ldr x7, [x1] ; load the handler at the branch target
br x7 ; branch site if branch is taken
LBB1242_2:
ldr x7, [x1, #16]! ; bump ip by 16 bytes and load next handler
br x7 ; branch site if branch is not taken
注意: 興味深いことに、この修正によるパフォーマンスの向上は、Wasmi 2.0のテールコールベースの命令ディスパッチ構成でのみ見られます。
次は何をするか
次のメジャーバージョンであるWasmi 3.0では、WebAssembly 3.0のすべてをサポートすることを目指しており、Wasmi 2.0でまだ欠けている以下のWasm提案の実装が必要です。
- 🚧
function-references - 🚧
exception-handling - 🚧
gc(ガベージコレクション)
試してみてください!
Wasmiを今日、様々な方法で試して使ってみてください。
- 📚
wasmiクレートをライブラリ依存関係として。 - 🖥
cargo install wasmi_cliまたはその事前ビルド済みリリースアーティファクトのいずれかを使用してwasmi_cliクレートをインストールすることにより、そのCLIアプリケーションを使用。 - ⚙️ C言語インターフェース言語でWasmi C-APIを使用して。
- 😈 Wasmi 2.0で動作するブラウザでDoomをプレイ。
- 📦 または、主要な既知のユーザーのいずれかを使用することで、間接的にWasmiを楽しんでください。
個人的なメモ
Stellar Development Foundationのスポンサーシップなしには、Wasmi 2.0は今日存在し得ませんでした。この資金提供により、私はこのオープンソースプロジェクトに2年間フルタイムで取り組むことができ、これは非常に稀な機会であり、深く感謝しています。
そのスポンサーシップは2026年10月に終了します。私はそれ以降もWasmiに取り組み続けるつもりであり、それを可能にする方法を探しています。別のスポンサーシップ、または少なくともパートタイムでWasmiの開発を続ける余地のある役割です。
もしあなたが、またはあなたの会社がそれに興味があるなら、robin.freyler@gmail.comまでご連絡ください。
注釈
- 私はネイティブ英語話者ではなく、この記事は手書きです。記事に含まれるすべての間違いは私のものです。重大な問題が発生した場合は、プルリクエストを開いてください。[↩︎]
cargo-bloat-showツールを使用して、最も肥大化の原因となっている部分を調査し、削除するのは非常に簡単でした。[↩︎]- 「安定した燃料計測」とは、機能が安定した(すでに安定していた)という意味ではなく、実行単位あたりの計測燃料がWasmiバージョン間で一定であることを意味します。[↩︎]
- WasmtimeのPulleyとWAMRのfast-interpreterは、インタプリタアーキテクチャの違いにもかかわらず、記事全体でベンチマークに示されています。これは、Wasm3、Stitch、Wasmi 2.0と比較して、それらが依然として相当なパフォーマンスを提供するためです。[↩︎]
- PulleyはCraneliftの高価な最適化パイプラインの後ろに位置しており、最適化されていないWasm入力では簡単に1位を獲得できる最適化Wasmインタプリタとなっています。しかし、実際には、Wasmは出荷前にほぼ常に最適化されています。[↩︎]
eager、lazy、lazy-translationなどの設定については、こちらで説明しています。[↩︎]- Wasmiのデフォルトの
stableクレート機能を無効にし、unstable機能を有効にすると、WasmiはスレッドコードディスパッチのためにRustの不安定なbecomeキーワードを利用します。[↩︎] - 関数呼び出し、
become、またはreturnの代わりにnext!マクロを使用しています。これは、Wasmiが同じ基盤コードを使用して異なる命令ディスパッチモードを使用できるようにするためです。next!マクロは、選択された構成に応じてわずかに異なるコードに展開されます。[↩︎] - 例えば
(global 0)への高速アクセス用にInstanceEntityにglobal0ポインタを配置することもできましたが、グローバルアクセスはすでにかなり高速であり、グローバルへのアクセスは通常ホット実行パス上にないため、現時点では見送りました。[↩︎] - パフォーマンスがクリティカルでないWasmiエグゼキュータ外での使用のために、
AnyHandleAndEntity型にhandleを保持しています。[↩︎] - Wasmi 2.0は、
execute/counter-globalにおいてWasm3とStitchの両方よりも高速です。しかし、これはおそらくインタプリタ間の他の技術的な違いによるものです。例えば、Wasm3はglobal.getの結果をスタックスロットに格納しますが、Wasmi 2.0はアキュムレータレジスタを使用しており、このベンチマークテストケースに有利です。[↩︎] - Wasmインタプリタのユーザーは通常、Wasm
simd提案を必要としませんが、バイナリ成果物のサイズ、コンパイル時間、および実行時のWasmバイトコードからWasmi IRへの翻訳にわずかにオーバーヘッドを追加します。したがって、simdはWasmiで有効になっているクレート機能のデフォルトセットには含まれていません。[↩︎] - Stitchとは対照的に、Wasmi 2.0のパフォーマンスはRust 1.91と1.92の間で低下しませんでした。そのため、ブランチサイトが収束した原因は異なりましたが、同じ結果に悩まされており、同じ簡単な修正で解決可能でした。[↩︎]
- Wasmi DoomはAIを使用して作成されたことに注意してください。[↩︎]