未来を形作るテクノロジーの深掘り記事。

WebAssemblyがJavaScriptより遅い理由

WebAssemblyは常に高速とは限りません。実測ベンチマークでTypeScriptが勝つ場面と、JS-WASM境界の横断がパフォーマンスを削る理由を解説します。

別のランナーが自由に駆け抜ける中、国境の検問所で足止めされるランナー

先月の土曜日、私はRustからWASMにコンパイルしていた画像メタデータパーサーをTypeScriptで書き直すことに丸一日費やしました。WASM版は8か月間本番で動いていて、問題もなく、誰も文句は言っていませんでした。ただ、パフォーマンストレースを眺めているうちに引っかかることがあったのです。WASMパーサーは、実際の解析よりもJS-WASM境界をまたぐデータの移動に多くの時間を使っていました。そこで書き直しました。純粋なTypeScriptで、WASMは使わずに。結果として、中央値のペイロードで38%高速になりました。アルゴリズムを良くしたわけではありません。境界越えの税金を払うのをやめただけです。

その経験で、頭の中の何かが壊れました。私もWebAssemblyは無条件に速い選択肢だと思い込んでいた開発者の一人でした。ところが、その思い込みは多くの人が思っているより頻繁に間違っているのです。

「WASMは常に速い」という神話はもう捨てるべき

多くの開発者が頭に抱えているのは、こんなメンタルモデルでしょう。コンパイル言語はWASMになり、WASMはほぼネイティブ速度で動く。だからWASMはJavaScriptより速い、というものです。この連鎖の各ステップはおおむね正しいのですが、結論は成り立ちません。計算そのものの周りにかかるコスト、つまりデータを入れること、結果を取り出すこと、そして2つのランタイムを並べて動かすオーバーヘッドを無視しているからです。

JavaScriptエンジンは驚くほど優秀です。V8、SpiderMonkey、JavaScriptCoreは、地球上で最も裕福な企業が資金を出してきた数十年にわたる最適化の成果です。投機的JITコンパイル、インラインキャッシュ、エスケープ解析、隠しクラスの遷移などを行います。多くのワークロード、特にWebアプリによくある文字列中心・オブジェクト中心・コールバック中心のパターンでは、最新のJSエンジンが生成する機械語は、静的コンパイラの出力に驚くほど近いのです。

WASMにはそうした最適化がありません。AOTコンパイルされ、出荷したものがそのまま実行されます。予測可能性という点では利点ですが、JITのように実行時のパターンに合わせて適応することはできません。

境界越えの問題:千回の呼び出しで命を落とす

これが最大の問題です。JavaScriptからWASMへの呼び出し(またはその逆)には、必ずオーバーヘッドがあります。エンジンはデータをマーシャリングし、型を検証し、実行コンテキストを切り替えなければなりません。1回の横断は安上がりで、数マイクロ秒程度です。しかし、1回の処理の中で境界を何千回も横断するワークロードは、このオーバーヘッドにやられてしまいます。

こうしたパターンは本当によく見かけます。チームがRustでパーサーやトランスフォーマー、バリデーターを書き、WASMにコンパイルして、ノードごと・トークンごと・ステップごとにWASMを呼び出すJavaScriptのグルーコードで包む。WASMのコア単体では非常に速いかもしれませんが、グルーコードがそれを有料道路に変えてしまうのです。

// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source);          // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) {                     // JS land
wasmParser.transform(node, newValue);      // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.

実際のコードベースで、まさにこのシナリオをベンチマークしました。WASM版は設定ファイル100件のバッチを340msで処理しました。TypeScriptへの移植版は195msでした。TypeScriptが速い言語だからではありません(そうではないのです)。処理が一度も単一の実行コンテキストから出なかったからです。

なぜ文字列はWASMの性能を破壊するのか

WASMはリニアメモリ、つまりバイトのフラットなバッファ上で動きます。一方、JavaScriptの文字列はまったく別物です。エンジンが管理するオブジェクトで、Latin1、UTF-16、ロープ、コンズ文字列といった特殊な内部形式で保持されます。JSからWASMへ文字列を渡すには、UTF-8へエンコードし、リニアメモリに領域を確保し、バイトをコピーする必要があります。戻すときは再びコピーしてデコードします。

数値計算のコードなら、これは問題になりません。TypedArrayを渡して、数値を受け取るだけだからです。しかし、パーサー、テンプレートエンジン、バリデーター、シリアライザーのように文字列を多用する処理では、文字列の断片ごとに「エンコード・コピー・処理・コピー・デコード」という税金を払い続けることになります。

// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.

WASMパーサーをプロファイルしたところ、実行時間全体の31%が文字列のシリアライズに費やされていました。解析でもツリー構築でもありません。境界を行き来させるための文字列の移動だけです。TypeScript版では、この種の作業がまるごと消えました。

ホットパスが文字列中心なら、WASMはおそらく速くするどころか遅くしています。シリアライズのオーバーヘッドは現実にあり、しかも急速に積み重なります。

誰も語らないJITコンパイルの強み

WASMのAOTモデルは、予測可能で一貫したパフォーマンスをもたらします。これは一部のユースケースでは素晴らしいことです。しかし、JavaScriptのJITモデルでは、V8がコードの実行を観察し、実際に流れてくるデータに合わせて最適化された機械語を生成します。時間が経つと、JITでコンパイルされたコードは非常識なほど速くなることがあります。

例えばオブジェクトの形状の特殊化です。同じプロパティを同じ順序で持つオブジェクトの配列を処理すると、V8はその単相的なパターンを検出し、固定のメモリオフセットでプロパティにアクセスする機械語を生成します。ハッシュテーブルの検索も型チェックもありません。動的なJavaScriptからCの構造体並みの性能が得られるのです。

// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number;   // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.

WASMにはこれができません。性能特性はコンパイル時に固定されます。タイトで予測可能な数値ループならそれで問題ありません。Rustコンパイラがすでに徹底的に最適化しているからです。しかし、Webアプリの大半を占める、雑多で多態的でコールバックの多いコードでは、実行時に特殊化できるJITの能力が本当のアドバンテージになります。

バンドルサイズとコールドスタート:見落としがちな指標

スループットだけが性能指標ではありません。ユーザーは読み込み時間、操作の遅延、最初の結果が出るまでの時間を体験します。WASMはこの3つすべてで実際のコストを抱えています。

  • wasm-bindgenを使ったRust製WASMモジュールは、gzip圧縮後で通常100KB〜1.5MBになります。同等のTypeScriptをminifyしてgzip圧縮すると、通常10〜40KBです。
  • WASMモジュールは実行前にネイティブコードへコンパイルされなければなりません。ストリーミングコンパイルで助かりますが、ブラウザがやるべき作業であることに変わりはありません。
  • JavaScriptエンジンは遅延パースを行います。関数は呼び出されるまでコンパイルされません。WASMは最初から全体のコンパイルコストを払います。
  • V8のコードキャッシュにより、再訪問時にはJSのパースを丸ごと飛ばせます。WASMのコンパイルキャッシュも存在しますが、成熟度は劣ります。

プロジェクトの実際の比較を紹介します。WASMパーサーのバンドルはgzip圧縮後380KBでした。TypeScriptの代替は26KBです。良い回線ならまあ問題ありません。すべての性能レビューでシミュレートしている、スロットリングされた3G回線では、WASM版が読み込み時間を1.8秒も増やしました。誤差ではありません。ユーザーが留まるか離脱するかの差です。

コールドスタートも重要でした。WASMモジュールは中位のスマートフォンでコンパイルとインスタンス化に約110msかかりました。TypeScript版はおよそ20ms以内に操作可能になりました。ユーザーが1日に何十回も使うツールでは、この差は確実にストレスとして積み重なります。

WASMが本当に勝つ場面(そして使うべき場面)

WASMが悪い技術だという印象は与えたくありません。WASMは素晴らしい技術で、得意な領域がはっきりしています。問題は、人々がその領域の外で使い、なぜ遅くなったのか首をかしげることなのです。

WASMが勝つのは、計算が重く、境界の横断が少なく、データが数値のときです。例えば、ピクセルバッファに対する画像フィルター、暗号ハッシュ、物理シミュレーション、オーディオDSP、ビデオコーデックなどです。大きなデータのかたまりをリニアメモリに流し込み、WASMにタイトなループで計算させ、結果を取り出す。入るときに1回、出るときに1回の横断で、その間に大量の計算をする。それがWASMの輝く場所です。

決定的な性能が必要なときもWASMが有利です。JITコンパイルは強力ですが予測しにくいものです。JS関数の最初の数回の呼び出しはインタプリタで実行され、その後ベースラインコンパイル、場合によっては最適化コンパイルされます。最初の呼び出しから一貫したレイテンシが必要な場合(ゲームループ、リアルタイムオーディオ、金融計算など)、WASMのAOTコンパイルがそれを提供します。

そして既存のネイティブコードベースを移植する場合は、WASMが明らかに正解です。WASMのオーバーヘッドを避けるためにSQLiteをJavaScriptで書き直す人はいないでしょう。それは正気の沙汰ではありません。既存のCコードには、あなたが再現できない数十年分の最適化が詰まっています。

実践的な判断フレームワーク:WASMかTypeScriptか

3つのプロジェクトでこの作業を行った結果、シンプルなチェックリストにたどり着きました。完璧ではありませんが、これのおかげで2回ほど判断を誤らずに済みました。

  1. 境界の横断回数を数えてください。WASMモジュールがJS(またはその逆)を1回の処理で数百回を超えて呼び出すなら、TypeScriptがおそらく勝ちます。確かめるためにプロファイルしてください。
  2. データ型を確認してください。主に数値とTypedArrayですか?ならWASMが適しています。主に文字列、オブジェクト、コールバックですか?ならJSのままにしてください。
  3. 起動コストを測ってください。コードがページ読み込み時やユーザーのクリックに応じて実行されるなら、WASMのコンパイルオーバーヘッドが効いてきます。長時間動くWorkerで実行されるなら、起動コストは償却されて消えます。
  4. バンドルの予算を確認してください。すでにバンドルサイズに苦労しているなら、400KBのWASMモジュールを追加すると確実に痛手になります。
  5. 開発体験のコストを考慮してください。WASMはRustやC++のビルドシステム、ソースマップの複雑さ、デバッグの難しさを持ち込みます。性能向上がわずかなら、その複雑さに見合いません。
  6. 実データでベンチマークしてください。マイクロベンチマークは嘘をつきます。オーバーヘッドのパターンは、本番規模の入力と現実的な呼び出しパターンでしか現れません。

書き直しから学んだこと:移行前後の比較

パーサー移行の実際の数字を並べます。これは合成ベンチマークではなく、50行から2,000行までの200ファイルからなる実際のドキュメントコーパスを処理した結果です。

Metric                  | Rust/WASM    | TypeScript   | Delta
------------------------|--------------|--------------|--------
Median parse time       | 23.1ms       | 14.4ms       | -38%
P99 parse time          | 58.3ms       | 30.7ms       | -47%
Bundle size (gzip)      | 380KB        | 26KB         | -93%
Cold start              | 142ms        | 18ms         | -87%
Boundary crossings/doc  | ~12,000      | 0            | -100%
String serialization    | 31% of time  | 0%           | eliminated
Debug/profile ease      | painful      | native       | huge win

TypeScriptへの書き直しは直訳ではありませんでした。V8のオプティマイザーと相性が良くなるようにASTの表現を再設計しました。単相的なオブジェクトの形状、文字列タグの代わりの数値列挙型、ポインタの多いツリーの代わりのフラットな配列ストレージ。Rustでは、Rustコンパイラがそこまで細かい部分を扱ってくれるので、そもそも考えることがない最適化です。JavaScriptでは、JITに半分歩み寄る必要があります。

// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number;       // numeric, not string — cheaper comparisons
start: number;      // byte offset
end: number;
parent: number;     // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number;      // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}

重要な気づきは何でしょうか?最速のコードとは、最速の言語で書かれたコードではありません。ランタイムに逆らわず、ランタイムと協調するコードのことです。当時のRustコードは見事なRustでした。しかし、WASMにコンパイルしてJavaScriptアプリに組み込んだ途端、あらゆる場面でプラットフォームと戦っていたのです。

推測はやめて、計測を始めよう

今でもWASMは使っています。生のピクセルバッファを境界の横断ゼロで操作するため、JavaScriptで書けるどんなものより4倍速いWASMベースの画像処理パイプラインがあります。適材適所です。

ただ、性能が必要なときにWASMをデフォルトで選ぶのはやめました。今のデフォルトは、まずTypeScript版を書き、実データで計測し、プロファイラーが必要だと示したときにだけWASMに手を伸ばすことです。しかも、実際に遅い特定のホットパスだけ。モジュール全体でも機能全体でもありません。本当にAOTコンパイルの恩恵を受けるタイトな計算ループだけです。

まずTypeScriptで書く。計測する。十分速ければ出荷する。速くなければ、ホットループ、それもホットループだけをWASMに移す。コードは少なくなり、バンドルは小さくなり、アプリは速くなります。

Webプラットフォームには強力な実行モデルが2つあります。両方使いましょう。ただし、直感が使うべきだと言う場所ではなく、実際に役立つ場所で使ってください。