RustのWASMパーサーをTypeScriptで書き直したら3倍速くなった話──WASM境界コストの罠

元記事を読む
キュレーターコメント

「RustはWASMで使えば速い」という思い込みを丁寧なベンチマークで崩した良質な実験記録。WebフロントエンドでWASMを検討している・すでに使っているエンジニアには必読の内容で、境界コストの罠はあらゆるWASMプロジェクトに潜んでいる。

概要

「RustはC++並みに速い」「WASMはブラウザでネイティブ速度を実現する」──この二つの前提を信じてパーサーをRust+WASMで実装したところ、TypeScriptに書き直したら3倍以上速くなったという衝撃的な実験結果が公開された。Hacker Newsで242ポイントを集めたOpenUIチームのブログ記事を詳しく読み解く。

背景:なぜRust+WASMで作ったのか

OpenUIは、LLMが出力するカスタムDSLをReactコンポーネントツリーに変換するopenui-langパーサーを開発している。ストリーミングチャンクごとに呼び出されるため、レイテンシが非常に重要なパイプラインだ。

パイプラインは6ステージで構成される。

  • Autocloser:ストリーム途中の不完全なテキストを構文的に有効にする
  • Lexer:1パスで文字をスキャンし型付きトークンを生成
  • Splitter:トークン列を id = expression 文に分割
  • Parser:再帰降下でAST構築
  • Resolver:変数参照のインライン展開(巻き上げ・循環参照検出)
  • Mapper:内部ASTをReactレンダラーが消費するOutputNode形式に変換

Rustは速く、WASMはブラウザでほぼネイティブ速度──論理的には完璧な選択に見えた。

問題の核心:WASM境界コスト

実はRustのパース処理自体は速い。遅かったのはJS⇔WASM間のデータ往来だった。

wasmParse(input)
  → 文字列をJSヒープからWASMリニアメモリへコピー(malloc + memcpy)
  → Rustがパース(✓ 速い)
  → serde_json::to_string() でJSON文字列にシリアライズ
  → JSON文字列をWASMからJSヒープへコピー(malloc + memcpy)
  → JS側でJSON.parse() してオブジェクト化
  → ParseResult 返却

「ならJSONラウンドトリップを省けばいい」と思い、serde-wasm-bindgenを試した。RustのstructをJsValueに直接変換してJSONを省略する手法だ。結果は30%遅くなった

なぜか。WASMリニアメモリ上のRustの構造体は、JSのオブジェクト表現とメモリレイアウトが根本的に異なる。serde-wasm-bindgenは再帰的にRustデータをJS配列・オブジェクトへ変換するため、1回のparse()呼び出しで大量の細粒度な境界越えが発生する。

一方、JSONアプローチは serde_json::to_string() が純Rust内で完結(境界越えゼロ)し、1回のmemcpyでJSへ転送、V8のネイティブC++実装のJSON.parseが一括処理する。「多くの小さな操作」より「少数の大きな操作」が勝るという教訓だ。

FixtureJSONラウンドトリップserde-wasm-bindgen差分
simple-table20.5µs22.5µs9%遅い
contact-form61.4µs79.4µs29%遅い
dashboard57.9µs74.0µs28%遅い

解決策:境界を完全に排除する

チームはパーサー全体をTypeScriptで書き直した。同じ6ステージアーキテクチャ、同じParseResult出力形式──ただしWASMなし、V8ヒープ上で完結。

FixtureTypeScriptWASM高速化率
simple-table9.3µs20.5µs2.2x
contact-form13.4µs61.4µs4.6x
dashboard19.4µs57.9µs3.0x

さらに、ストリーミングアーキテクチャのO(N²)問題も発見した。チャンクごとに蓄積文字列全体を再パースしていたため、1000文字を20文字チャンクで受け取ると50回のparse呼び出しで累計約25,000文字を処理することになる。

これをステートメントレベルのインクリメンタルキャッシュで解決した。「深さ0の改行で終端された文」はLLMが後から変更しないという性質を利用し、完了済みステートメントをキャッシュ。次のチャンクでは未完了の末尾部分だけを再パースする。

まとめ・所感

この事例から得られる教訓は明快だ。

  • ボトルネックを計測してから最適化する:「Rustは速いはず」という思い込みが見当違いの最適化を招いた
  • 境界コストは見えにくい:WASM自体のパフォーマンスではなく、JS⇔WASM間のデータ転送が律速になるケースは珍しくない
  • 適材適所:ブラウザで重い計算(画像処理・暗号・物理演算)をオフロードするWASMの本来の強みは活きる。しかし「頻繁に呼ばれる・入出力が小さい・V8がすでに最適化している」ケースはTypeScriptの方が速くなりうる
  • インクリメンタル処理でO(N²)をO(N)へ:ストリーミングパイプラインでは再計算コストの設計が重要

Rust+WASMを採用する際は、パース・計算本体の速さだけでなく呼び出し頻度・データ転送量・境界越えの回数を事前に見積もることが不可欠だ。元記事にはベンチマーク手法の詳細やインクリメンタルキャッシュの実装方針も掲載されているので、WASMを活用しているプロジェクトは一度読んでみることを強くすすめる。