Why WASM Lost to js-yaml
js-yamlをRustで置き換えて爆速にしてやろう、ともくろんだことがあった。Rustもたまには書いてみろという周りの圧に負けた結果でもある。名前を「fast-yaml」にした。ベンチマークを取ったら負けていた。
懲りずにC++で作り直した。READMEに「20× faster」のバッジを貼った。ベンチマークを記録する前に。結局リポジトリすら作らなかった。
これは、WASMが「速くなる処理」と「速くならない処理」の境界線の話だ。
js-yamlの意外な手強さ
js-yamlはピュアJavaScriptで書かれたYAMLパーサーだ。V8のJITコンパイラがJavaScriptを機械語に変換し、ホットパス(頻繁に実行される経路)を検出して最適化する。文字列操作やオブジェクト生成はV8が最も得意とする領域で、何十年もかけてチューニングされている。
YAML 1.2の仕様準拠という点では、js-yamlは完璧ではない。YAML Test Suite(351ケース)のうち約89ケースで非準拠の振る舞いが確認されている。仕様に対して甘い部分がある。
YAML 1.2は見た目より複雑
YAMLは「読みやすい設定ファイル形式」に見える。だが仕様書は84ページあり、エイリアス、アンカー、タグ、複数ドキュメント、フロースタイルとブロックスタイルの混在、スカラーの5種類のクォーティングスタイルなど、パーサーの実装は相当に厄介だ。yesが真偽値として解釈されるかどうかすらYAML 1.1と1.2で異なる。
YAML Test Suiteとは
YAML仕様の準拠度を機械的に検証するための公式テストスイート。各ケースはYAML入力、期待されるイベントツリー(+STR +DOC +SEQ +MAP =VAL のようなトークン列)、そしてJSONでの等価表現を含む。仕様書の例示(Spec Example 2.4: Sequence of Mappingsなど)から、エイリアスの循環参照、フロースタイルとブロックスタイルの混在、暗黙のキーなど、エッジケースまで351ケースが収録されている。パーサーの正しさを測る事実上の標準だ。
fast-yamlはRustのyaml-rust2を内部に使い、YAML Test Suite全351ケースをパスした。仕様準拠という点では、fast-yamlの方が正確だった。問題は速度だった。
Rust + WASM:最適化の記録
初期実装
初期の設計は素朴だった。yaml-rust2でYAMLをパースし、結果をRustのAST(構文木)として受け取り、それをJavaScriptのオブジェクトに変換して返す。
この設計のボトルネックは明白だ。YAMLのノード一つひとつに対して、WASM↔JSの境界を越えてJavaScriptオブジェクトを生成している。1MBのYAMLファイル(約10,000アイテム)を処理すると、境界越えが数万回発生する。
さらに、初期実装には console.log がホットパスに入っていた。デバッグ用のログがベンチマークに混入していた初歩的なミスだ。
最適化の試み
3つの手を打った。
1. console.log の除去
ホットパスのログ出力を全削除。これだけでも多少の改善はあったが、根本的ではなかった。
2. wasm-opt の有効化
WASMバイナリの最適化ツール wasm-opt を有効にした。-O3(速度最適化)と -Oz(サイズ最適化)を適用。LTO(リンク時最適化)、codegen-units = 1、panic = 'abort' は最初から有効にしていた。
3. JSON文字列ブリッジ
最も大きな設計変更。ノードごとにJSオブジェクトを生成する代わりに、WASM内部でYAMLをJSON文字列に変換し、その文字列一つだけをJS側に渡して JSON.parse() で復元する方式に切り替えた。
境界越えが数万回から1回に減る。実装もシンプルになった。
ベンチマーク結果
hyperfineで計測した結果(ウォームアップ3回、計測10回の平均)。
| サイズ | fast-yaml (Rust/WASM) | js-yaml | 倍率 |
|---|---|---|---|
| 10KB | 36.4ms | 30.3ms | 0.83×(20%遅い) |
| 100KB | 38.0ms | 36.3ms | 0.95×(5%遅い) |
| 1MB | 90.4ms | 69.4ms | 0.77×(30%遅い) |
最適化前の1MBは106.5msだったので、15%の改善は達成した。100KBではjs-yamlの95%まで迫った。しかし、すべてのサイズで負けている。
最も注目すべきは、ファイルが大きくなるほど差が開くという傾向だ。10KBで20%遅く、1MBで30%遅い。WASMのオーバーヘッドがデータサイズに比例して効いている。
ベンチマークの条件
テストデータは合成されたYAMLファイル(ネストされたオブジェクト配列)。hyperfineはNode.jsプロセスごと起動するため、WASMモジュールの初期化コストも含まれている。ホットループでの比較ならもう少し差は縮まる可能性があるが、実運用では初期化コストも実コストだ。
なぜWASMは負けたのか
境界コスト
WASM↔JSの境界越えには必ずコストがかかる。JSON文字列ブリッジにしても、文字列のコピーが発生する。WASM側のリニアメモリにある文字列を、JS側のヒープにコピーし、それを JSON.parse() が再度走査してJSオブジェクトを構築する。パース処理が二重になっている。
赤で示した2つのステップが、js-yamlには存在しない。js-yamlはYAML文字列を直接JSオブジェクトに変換する。中間表現を経由しない。
V8のJIT最適化
V8のJITコンパイラは、JavaScriptの文字列操作とオブジェクト生成に対して高度に最適化されている。Hidden Classによるプロパティアクセスの高速化、インライン展開、Escape Analysisによる不要なオブジェクトの除去——これらはWASM経由では享受できない。
js-yamlが生成するオブジェクトは毎回同じ構造を持つ傾向があるため、V8はHidden Classを効率的に再利用できる。WASMから JSON.parse() で生成するオブジェクトも同じ恩恵は受けるが、そこに至るまでの文字列構築と転送のコストが上乗せされる。
YAMLパーサーの特性
YAMLパーサーは、入力が文字列で出力がJSオブジェクトという、入出力の両方がJS側のデータ型であるタスクだ。WASMが得意とするのは、入力を受け取ったあとWASM内部で大量の計算を行い、結果を返すパターン——音声分析、画像処理、数値シミュレーションなど。入出力のコストを計算コストが圧倒する場合にWASMは速い。
YAMLパースの計算そのものは軽い。ほとんどの時間はトークンの読み取りとオブジェクトの構築に費やされる。計算が軽い処理では、境界コストが全体に占める割合が大きくなる。
C++版:もう一度
Rust版の敗北を受けて、C++版を作った。ただし今度はWASMではなく、Node.jsのN-API(ネイティブアドオン)を使った。
N-APIはWASMとは異なるアプローチだ。C++のコードがNode.jsプロセス内で直接実行され、V8のAPIを通じてJSオブジェクトを生成する。WASMのようなリニアメモリ間のコピーが不要なぶん、境界のオーバーヘッドは小さい。
内部パーサーにはrapidyaml(ryml)を選んだ。C++ベースの高速YAMLパーサーで、yaml-rust2よりもパース速度は速い。JSON Schema バリデーション用にRapidJSONも組み込んだ。
READMEには目標値を書いた。1MBの load でjs-yaml 40msを2ms以下にする。20倍速。GitHubバッジも貼った。緑色の「20× faster」が光っていた。ベンチマークを記録する前に。
N-APIとWASMの違い
WASM: 言語をWASMバイトコードにコンパイルし、V8のWASMランタイムで実行する。サンドボックス化されており、JSとの通信には明示的な境界越えが必要。クロスプラットフォームで単一の .wasm ファイルが動く。
N-API: C/C++のコードをNode.jsのネイティブアドオンとしてコンパイルする。V8のAPIに直接アクセスできるため、JSオブジェクトの生成コストは低い。ただし、プラットフォームごとにバイナリを用意する必要がある(Windows x64, macOS arm64, Linux glibc等)。
設計
C++版はRust版の教訓を活かし、本格的な設計で臨んだ。
- 依存注入によるScanner/Emitter/Validatorの分離
- N-APIの同期APIと非同期API(libuvワーカースレッド)の両方を実装
- js-yamlのAPI完全互換(
load,dump,safeLoad,safeDump,loadAll) - JSON Schemaバリデーション
- コメント保持
- CLIツール(lint, fmt, bench)
C++ソース15本、TypeScriptソース15本。GoogleTestとJestの両方でテストを書き、js-yamlのエッジケースを網羅的に抽出したテストフィクスチャも用意した。
ベンチマーク結果は残していない。ソースコードはローカルに眠ったまま、GitHubに公開されることもなかった。察してほしい。
なぜC++でも勝てないのか
N-APIはWASMより境界コストが低いが、ゼロではない。C++側でパースした結果をJSオブジェクトに変換する際、V8のAPIを通じてプロパティを一つずつ設定する必要がある。
結局のところ、js-yamlはJavaScriptからJavaScriptオブジェクトを直接生成する。中間層がない。どんなに高速なパーサーを中間に置いても、最終的にJSオブジェクトを構築するコストは同じだ。パーサー自体が高速であればあるほど、オブジェクト構築のコストが全体に占める割合が増える。
WASMが勝つ条件
わたしは他にもWASM/C++のプロジェクトを複数作っている。全文検索エンジン、日本語トークナイザー、音楽生成エンジン、音声分析ライブラリ。これらはWASMで動かしても速い。fast-yamlだけが負けた。
違いは何か。
| プロジェクト | 入力 | 出力 | WASM内部の計算 | 結果 |
|---|---|---|---|---|
| 全文検索エンジン | クエリ文字列 | 文書IDリスト | 転置インデックス走査、スコアリング | ✔ |
| 日本語トークナイザー | テキスト | トークン配列 | ラティス構築、ビタビ探索 | ✔ |
| 音楽生成 | シードパラメータ | MIDIデータ | 対位法制約評価、マルコフ連鎖 | ✔ |
| 音声分析 | 音声データ | BPM/キー/コード | FFT、HPSS、クロマ | ✔ |
| YAMLパーサー | YAML文字列 | JSオブジェクト | トークン読み取り | × |
パターンが見える。WASMが勝つのは、WASM内部の計算が重く、JS側とのデータ往復が少ない場合だ。
音声分析ではFFTやHPSSといった浮動小数点の大量計算がWASM内部で完結する。出力はBPMやキーといった小さな値。境界コストは全体のごく一部。日本語トークナイザーもラティス探索がWASM内部で完結し、結果のトークン列だけを返す。
YAMLパーサーは逆だ。入力はJS文字列、出力はJSオブジェクト。中間の計算(トークン読み取り、構文木構築)はパーサーとしては軽い部類で、V8のJITコンパイラが最も得意とする領域だ。
教訓
WASMが「ネイティブに近い速度」で動くのは事実だ。ただしそれは、WASM内部の計算に限った話であり、JSとの境界越えにはコストがかかる。入出力がJS型で、内部の計算が軽い処理——つまりJSのランタイムが最も最適化されている領域に挑む場合、WASMは負ける。
fast-yamlはRust版もC++版も、npmに公開されることはなかった。YAML Test Suite 351ケースをパスし、仕様準拠という点ではjs-yamlより正確だった。ただ、速くなかった。
「fast」を名乗ったプロジェクトが遅かったというのは笑い話だが、どこにWASMを使うべきで、どこに使うべきでないかを判断する材料にはなった。READMEに貼った「20× faster」のバッジだけが、まだローカルのリポジトリに残っている。