
2026/03/21 6:48
Rust‑WASM パーサーを TypeScript に書き直した結果、処理速度が 3 倍に向上しました。
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
(欠落した詳細を補完し、推測による提案を除外したもの)
要約
本研究では、openui‑lang パーサーの Rust でコンパイルされた WebAssembly (WASM) 実装と純粋な TypeScript バージョンをベンチマーク比較しています。
- パイプライン – WASM パーサーは次の六段階を経て実行されます: autocloser → lexer → splitter → parser → resolver → mapper → ParseResult。
- 相互運用オーバーヘッド – 各 WASM 呼び出しでは、入力文字列を WASM メモリにコピーし、
で Rust の結果を JSON にシリアライズし、その JSON を JavaScript に戻して V8 でパースする必要があります。serde_json::to_string() - JsValue と JSON ラウンドトリップ –
を使用して JsValue を直接返す方法は、単一の大きな JSON 転送よりも 30 % 遅く、多数の細かい境界横断が必要でした。1,000 回の実行でベンチマークした結果、JSON ラウンドトリップはすべてのフィクスチャ(simple‑table +9 %、contact‑form +29 %、dashboard +28 %)で直接 JsValue を上回りました。serde-wasm-bindgen - 純粋 TypeScript のパフォーマンス – WASM 境界を除去した一度きりの TS パースは、すべてのフィクスチャで WASM バージョンより 2–3 倍速でした。
- ストリーミングの複雑さ – 単純なストリーミング手法では、各 LLM チャンクごとに累積文字列全体を再パースし、O(N²) 時間が発生しました。すでにパース済みのステートメントをスキップする増分キャッシュを導入すると、これを O(N) に削減できました。20 文字チャンクの完全ストリームベンチマークでは、増分 TS パーサーは単純 TS パーサーより 2.6–3.3 倍速で、WASM と JSON ラウンドトリップを呼び出す場合と比べて 2.2–4.6 倍速でした。
- 結論 – WASM は構造化テキストを JavaScript オブジェクトにパースする際に大きな境界オーバーヘッドが発生します。計算負荷が高く、相互運用が少ないワークロード(例: 画像/動画処理、暗号化)は、WASM に適したままであると結論付けられます。
このバージョンは主要なポイントをすべて保持し、推測による提案を除外し、ベンチマークの詳細を明確にしています。
本文
私たちがRustで書いた openui‑lang パーサを WASM にコンパイルした理由
ロジックはまったく正しかった。
- Rust は高速
- WASM ならブラウザ上でもほぼネイティブ速度
- 我々のパーサはそれなりに複雑なマルチステージパイプライン
というわけで、Rust + WASM がベストだと考えていた。
しかし実際には「最適化すべきところ」を誤っていたのである。
パイプライン
| ステージ | 説明 |
|---|---|
| autocloser | 途中で途切れた文字列を、必要最低限の閉じ括弧/クォートで構文的に有効化する |
| lexer | 単一パスで文字を走査し型付きトークンを出力 |
| splitter | トークンストリームを という文単位へ切り分ける |
| parser | 再帰下降式パーサで AST を構築 |
| resolver | 変数参照をすべて inlined(hoisting と循環参照検出付き) |
| mapper | 内部 AST を React レンダラーが消費する フォーマットへ変換 |
パーサはストリーミングチャンクごとに呼び出されるため、レイテンシは極めて重要である。
WASM 境界オーバーヘッド
WASM パーサを呼び出すたびに発生する必須のオーバーヘッドは、Rust コード自体が高速であっても無視できないものだった。
JS 世界 | WASM 世界 ─────────────────────────────────────── wasmParse(input) │ ├─ JS ヒープ → WASM 線形メモリへ文字列コピー (割り当て + memcpy) │ │ Rust が高速にパース ✓ │ serde_json::to_string() ← 結果をシリアライズ │ ├─ WASM → JS ヒープへ JSON 文字列コピー (割り当て + memcpy) │ JSON.parse(jsonString) ← 結果をデシリアライズ │ return ParseResult
実際に遅いのは Rust のパース 自体ではなく、境界処理である。
文字列を WASM にコピーし、結果を JSON 化して再び JS に渡し、V8 がその JSON を解析するという一連の操作がコストを押し上げていた。
「直接オブジェクト渡し」は逆に遅い
serde-wasm-bindgen を使って Rust の構造体を JsValue として直接返す方法も試した。しかし 30 % 遅く なった。
理由は、JS が WASM 線形メモリ上のバイト列をそのままネイティブオブジェクトとして解釈できないためである。
Rust のデータ構造(ポインタ、enum 判別子、アラインメントパディングなど)は JS 実行環境には完全に透明であり、
serde-wasm-bindgen は「JS オブジェクトをフィールド単位で再構築」する必要がある。そのため
parse() 呼び出しごとに多数の細かい変換が発生する。
JSON アプローチと比較すると:
は純粋な Rust で実行され、境界を一度も越えないserde_json::to_string()- 生成された文字列は1回だけコピーされ、V8 のネイティブ C++
が単一の最適化済みパスで処理するJSON.parse
大きくて少数の操作 の方が多く細かい操作より勝る。
| Fixture | JSON ラウンドトリップ (µs) | 直接 JsValue (µs) |
|---|---|---|
| simple‑table | 20.52 | 25.9 – 29 % 遅い |
| contact‑form | 61.47 | 79 – 29 % 遅い |
| dashboard | 57.97 | 74 – 28 % 遅い |
この変更はすぐに取り消した。
TypeScript 実装への移行
パーサ全体を TypeScript に書き直した。
- ステージ数は同じ(6)
の形も同じParseResult- WASM は使わず、V8 ヒープ内で完結
ベンチマーク手法:ワンショットパース
| 何を測定するか | 実施方法 |
|---|---|
単一 の呼び出し時間 | JIT を安定させるために 30 回ウォームアップ、次に (µs 精度)で 1000 回タイムを測定。中央値を報告 |
| 使用データ | 実際の LLM が生成したコンポーネントツリーを各フォーマットでストリーミング構文化 |
| Fixture | TypeScript (µs) | WASM スピードアップ |
|---|---|---|
| simple‑table | 9.3 | ×2 |
| contact‑form | 13.4 | ×1.5 |
| dashboard | 19.4 | ×1.0 |
WASM を排除したことで、呼び出しごとのコストは劇的に減少。
しかしストリーミングアーキテクチャ自体にはさらに深い非効率が残っていた。
ストリーミングの非効率
パーサは LLM の各チャンクごとに呼び出される。
単純な戦略では、チャンクを蓄積して文字列全体を再度ゼロからパースする:
Chunk 1: parse("root = Root([t") → 14 chars Chunk 2: parse("root = Root([tbl])\ntbl = T") → 27 chars ...
1000 文字の出力を 20 文字ずつ配信した場合、
50 回のパースで約 25 000 文字を処理し、計算量は O(N²)。
修正:文単位増分キャッシュ
深さ 0 の改行で終端された文は不変(LLM は再度変更しない)とみなし、
完了した文の AST をキャッシュするストリーミングパーサを導入:
State: { buf, completedEnd, completedSyms, firstId } push(chunk) で 1. completedEnd 以降から深さ0改行をスキャン 2. 完了文ごとに parse + cache AST → completedEnd を進める 3. 残り(最後の不完全文): autoclose + fresh parse 4. キャッシュ済み + 未完結文をマージして resolve + map → ParseResult
- 完了した文は再パースされない
- チャンクごとに 残り1文だけ を再解析する
これで計算量は O(総文字数) へ改善。
ベンチマーク手法:全ストリーム総パースコスト
| 何を測定するか | 実施方法 |
|---|---|
| すべてのチャンク呼び出しにわたる累積パース時間 | 文書を 20 文字ずつ再生し、各チャンクで (ナイーブ)または (増分)を呼び出す。100 回再現し中央値を取る |
| Fixture | ナイーブ TS (µs) | 増分 TS (µs) | スピードアップ |
|---|---|---|---|
| simple‑table | 6977 | –(単一文のためキャッシュ効果なし) | なし |
| contact‑form | 3161 | 222.6 | ×2.6 |
| dashboard | 8402 | 553 | ×3.3 |
simple-table は単一文なので両者は同じ。文数が増えるほどキャッシュの恩恵は顕著に大きくなる。
発見事項まとめ
| 観点 | 数値 |
|---|---|
| 呼び出しごとのコスト | TypeScript ≈ 9–19 µs, WASM + JSON ラウンドトリップ ≈ 20–61 µs |
| 全ストリーム総コスト | TypeScript ナイーブ ≈ 69–8400 µs, 増分 ≈ 69–255 µs |
WASM から TypeScript に切り替えたことで、呼び出しごとのパフォーマンスは 2.2〜4.6 倍向上。
さらにアルゴリズムを O(N²) → O(N) に改善した結果、全ストリームコストは 2.6〜3.3 倍低減。
学んだ教訓
-
実際に時間がかかっている場所をプロファイルする
言語選択の前に、計算自体よりデータ転送(境界オーバーヘッド)がボトルネックになることが多い。 -
で直接オブジェクト渡しは必ずしも高速ではないserde-wasm-bindgen
Rust の内部レイアウトを JS が解釈できないため、フィールド単位で再構築する必要がある。結果として境界越え回数が増える。 -
アルゴリズム的改善が言語レベルの最適化よりも大きいインパクトを持つ
O(N²) から O(N) にした方が、WASM → TS の切替よりも実際に速くなるケースが多い。 -
WASM と JS はヒープを共有しない
WASM の線形メモリは生のバイト列であり、JS にはポインタや enum 判別子などが透明である。常に変換コストが発生する。
WASM が有効なケース
-
計算負荷が高く、相互作用が少ない
画像/動画処理、暗号化、物理シミュレーション、オーディオコード化など。入力は大きいが出力はスカラーか in‑place 修正のみで、境界越え頻度が極めて低い。 -
既存のネイティブライブラリを再実装せずに使いたい
SQLite, OpenCV, libpng など C/C++ ライブラリをブラウザへそのままデプロイできる。
WASM があまり有効でないケース
-
構造化テキストを JS オブジェクトに変換するパース
シリアライズコストは必須。Rust の高速計算が V8 の JIT を上回る余裕がなく、境界オーバーヘッドが支配的。 -
小さな入力で頻繁に呼び出される関数
呼び出し 50 回/ストリーム、1 回あたり計算時間 5 µs の場合、境界コストを摊り切れない。
結論
まずは アルゴリズムの効率化(O(N²) → O(N)) を優先し、次に「明らかなパフォーマンスメリットがある」場面でだけ言語/ランタイムを切り替える。
そうすることで、クリーンで高速、保守性の高いパーサーパイプラインを実現できた。