WebAssemblyの実行モデルとJSとの境界
なぜWasmは速いのに、JSと頻繁にやり取りすると遅くなるのか。スタックマシン・線形メモリ・型システムの原理から、JS境界の実コストとストリーミングコンパイルまでを解説します。
- 1.Wasmは型付きスタックマシンの命令列を、検証済み・固定型を前提にネイティブコードへコンパイルするため、起動も実行も速く予測しやすい。
- 2.メモリは連続した1本の線形メモリ(ArrayBuffer)で、JSとはこのバッファ越しにしか値を共有できない。文字列やオブジェクトは値型として直接渡せない。
- 3.速いのは関数内部であって境界そのものではない。JS↔Wasmの呼び出しや変換が頻発するとコストが顕在化するため、境界は粗く、データはまとめて渡すのが原則。
なぜWasmは速く、なぜ境界で遅くなるのか
WebAssembly(Wasm)は「ブラウザで動く速いバイナリ」と紹介されがちですが、それだけでは「どこが速くて、どこに落とし穴があるか」を説明できません。速さの源泉は、Wasmが事前に型が確定した検証済みの命令列であり、エンジンが推測をせずに機械語へ落とせる点にあります。一方で、Wasmは単独で完結せず、DOM操作や外部APIには必ずJavaScriptを経由します。このJS↔Wasm境界を頻繁にまたぐと、変換と呼び出しのコストが積み上がり、せっかくの速さが相殺されます。原理を押さえると、Wasmを「使えば速い道具」ではなく「境界設計しだいで速くも遅くもなる仕組み」として扱えます。
型付きスタックマシン:推測しない実行モデル
Wasmの命令はスタックマシンとして定義されます。レジスタを名前で指定するのではなく、オペランドを暗黙のスタックに積み、命令がスタックの頂上から値を取り出して結果を積み直します。たとえば local.get 0、local.get 1、i32.add という3命令は、「ローカル変数0と1をスタックに積み、加算して結果を積む」という意味になります。
重要なのは、各命令の入出力の型が静的に決まっていることです。i32.add は必ず2つの i32 を取り i32 を返します。値型は当初 i32 / i64 / f32 / f64 の4つで、後に参照型(funcref / externref)や128ビットの v128(SIMD)が加わりました。ロード時の**検証(validation)**で、スタックの型整合・到達可能性・境界が静的に保証されるため、エンジンは実行時に「この値は整数か?」と推測する必要がありません。
JavaScriptエンジンは動的型のため、実行中に型を観測し(インラインキャッシュ)、前提が崩れれば脱最適化で巻き戻します。Wasmは型が事前確定しているので、この「観測と賭け」が不要です。脱最適化が原理的に起きないことが、Wasmの実行時間の予測しやすさ(ジッタの小ささ)を生みます。
スタックマシンは概念モデルであり、実機がスタックを愚直に上下させるわけではありません。エンジンは検証済みのバイトコードをSSA形式の内部表現に変換し、ローカルやスタック上の値を実レジスタへ割り付けて機械語を生成します。スタック表現は「コンパクトで検証しやすいエンコード」を担い、実行性能はその後のコンパイラが受け持ちます。
線形メモリ:1本の連続バッファという制約
Wasmのメモリは線形メモリ(linear memory)と呼ばれる、バイト単位でアドレス可能な連続した1本のバッファです。JS側からはこれが WebAssembly.Memory として見え、その実体は ArrayBuffer です。Wasmのロード/ストア命令はこのバッファ内のオフセットを読み書きし、範囲外アクセスは検証ではなく実行時の境界チェックでトラップ(例外)になります。これがWasmの「サンドボックス」の核で、ホストのメモリ空間を直接触ることはできません。
ここから、JSとの相互運用における最大の制約が導かれます。Wasmが直接扱える値は数値(と参照)だけで、文字列・配列・オブジェクトといった構造を持つデータは、線形メモリ上のバイト列として表現し、そのオフセットと長さを数値でやり取りするしかありません。
// JS文字列をWasmへ渡す典型パターン(概念)
const bytes = new TextEncoder().encode("こんにちは");
const ptr = wasm.exports.alloc(bytes.length); // Wasm側でメモリを確保
const mem = new Uint8Array(wasm.exports.memory.buffer);
mem.set(bytes, ptr); // バッファへコピー
wasm.exports.process(ptr, bytes.length); // オフセットと長さを渡す
つまり「文字列を渡す」は、内部的にはエンコード+線形メモリへのコピー+ポインタ受け渡しです。オブジェクトをそのまま値として渡す手段はありません。なお、memory.grow でバッファを拡張すると ArrayBuffer が再確保されて古いビューが無効化される(detach)ため、memory.buffer を握りっぱなしにせず、拡張後は Uint8Array を取り直す必要があります。
new Uint8Array(memory.buffer) を一度作って保持していると、memory.grow 後に参照先が古いバッファのままになり、読み書きが壊れます。グローバルにキャッシュせず、確保後に作り直すか、grow のたびに張り替える設計にします。
JS↔Wasm境界の本当のコスト
「Wasmは速い」という言明は、関数の内部計算については正しい一方、境界をまたぐ行為には当てはまりません。境界コストは主に次の3つに分解できます。
| コストの所在 | 中身 | 効く場面 |
|---|---|---|
| 呼び出しオーバーヘッド | JS関数フレームとWasmフレームの相互遷移、引数のマーシャリング | 細かい関数を高頻度で呼ぶ |
| 値の変換 | JS数値(double)とWasm i32/i64/f32 等の相互変換、BigIntとi64の橋渡し | 型が一致しない引数・戻り値 |
| データのコピー | 文字列・配列を線形メモリへ符号化してコピー | 構造を持つデータを毎回渡す |
最新のエンジンは引数も戻り値も数値の単純な呼び出しを大きく最適化しており、純粋な呼び出しコスト自体は年々下がっています。それでも、境界を細かく多数またぐ設計は損です。たとえば配列の各要素ごとにWasm関数を呼ぶより、配列全体を線形メモリに置いて「一括処理する1関数」を呼ぶほうが、呼び出し回数も変換も激減します。原則は明快で、境界は粗く(chunky)、内部は細かくです。
ホットパスでJSとWasmを往復させないこと。データはまとめて線形メモリへ置き、処理の塊を1回の呼び出しで渡す。DOMアクセスのようにJSを経由せざるを得ない処理は、ループの内側からWasmが都度呼ぶのではなく、計算をWasm側で完結させてから結果だけJSへ返す形にします。境界の最適化は、コードの最適化より効果が大きいことが多いです。
DOMを直接触れない点も設計に影響します。Wasmは仕様上ホスト関数(インポート)越しにしか外界へアクセスできず、document 等の操作は必ずJSのグルーコードを通ります。externref は不透明なホスト参照を数値化せずWasm内で保持できるため、JSオブジェクトのハンドルをコピーせず受け渡せますが、その実体を操作するにはやはりJS側の関数が要ります。
ストリーミングコンパイルとJITの関係
Wasmの起動が速いもう一つの理由が、コンパイル戦略です。WebAssembly.instantiateStreaming(fetch(url)) を使うと、エンジンはダウンロードと並行してバイナリを検証・コンパイルします(ストリーミングコンパイル)。バイト列が届いた端から関数単位で処理できるため、全体のダウンロード完了を待ってからコンパイルを始める場合より、最初のコードが動くまでの時間が短くなります。これがWasmを ArrayBuffer から同期的に作るより推奨される理由です。
エンジン内部では、起動と最終性能を両立させるため二段(ティア)コンパイルが一般的です。
| 段階 | コンパイラ(例) | ねらい | 性質 |
|---|---|---|---|
| 第1ティア | Liftoff / baseline | とにかく速く機械語を出す | 起動が速い・最適化は浅い |
| 第2ティア | TurboFan / 最適化 | 実行の速いコードを出す | コンパイルは重い・実行が速い |
考え方はJSエンジンのJITと似ていますが、出発点が異なります。JSは動的型ゆえ「実行時に観測した型」を前提にしますが、Wasmは型がすでに確定しているため、第1ティアでも推測なしで正しい機械語を出せます。そのうえで頻繁に実行される関数を第2ティアで最適化し、関数を裏で差し替えます(ティアアップ)。Wasmにおける「JIT」は、JSのような型推測の意味は薄く、起動の速い浅い最適化から、重い深い最適化へ段階的に移すための仕組みと理解するのが正確です。
「ストリーミングコンパイル」はダウンロードと並行してコンパイルする話、「ティアコンパイル」は浅い最適化→深い最適化へ段階移行する話で、別々の概念です。前者は最初の応答性、後者は定常状態の実行性能に効きます。AOT(事前コンパイル)とも混同しがちですが、ブラウザのWasmは配布時こそバイナリでも、機械語化はクライアント側で行うため、性質としてはJITに近いです。
まとめ
Wasmは型付きスタックマシンの検証済み命令列で、エンジンが推測なしに機械語へ落とせるため、起動も実行も速く予測しやすい。メモリは1本の線形メモリ(ArrayBuffer)で、JSとはこのバッファ越しにしか値を共有できず、文字列やオブジェクトは符号化+コピーで渡すしかありません。だから速いのは関数の内部であって境界そのものではなく、JS↔Wasmの往復が増えるほどコストが顕在化します。境界は粗く、データはまとめて、計算はWasm内で完結させ、ストリーミングコンパイルで起動を、ティアコンパイルで定常性能を稼ぐ——この設計が、Wasmの理論性能を実アプリの速さに変えます。仕上げの計測はWebパフォーマンスの考え方と合わせて行うと、境界のどこが効いているかが見えてきます。
Web/フロントエンド Article
WebAssemblyの実行モデルとJSとの境界を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
WebAssembly
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
メモリは連続した1本の線形メモリ(ArrayBuffer)で、JSとはこのバッファ越しにしか値を共有できない。文字列やオブジェクトは値型として直接渡せない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「WebAssembly / Wasm」に近いか確認する。
- 強みである「Wasmは型付きスタックマシンの命令列を、検証済み・固定型を前提にネイティブコードへコンパイルするため、起動も実行も速く予測しやすい。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。