ハクソク

世界を動かす技術を、日本語で。

Rust製のWASMパーサーをTypeScriptで書き直したら、速度が向上しました

概要

  • Rust製WASMパーサは、JSとのデータ受け渡しコストがボトルネック
  • serde-wasm-bindgenによる直接オブジェクト返却はJSON経由より遅い
  • TypeScript移植+インクリメンタルパースで劇的な高速化を実現
  • WASMは計算量が大きく、境界をまたぐ回数が少ない用途で最適
  • アルゴリズム改善が言語最適化より実効効果大

Rust製WASMパーサの構造と課題

  • openui-lang parserはRustで実装、WASMへコンパイル
  • LLMが出力する独自DSLをReactコンポーネントツリーへ変換
  • 6段階パイプライン構成
    • autocloser:途中テキストの構文補完
    • lexer:トークン化
    • splitter:id=式単位で分割
    • parser:AST生成
    • resolver:変数参照解決・循環検出
    • mapper:ASTをReact用OutputNode形式へ変換
  • 各パース呼び出しで必ず発生するJS↔WASM間のデータコピーが遅延要因

JSON経由とserde-wasm-bindgenの比較

  • 従来:**serde_json::to_string()**でRust側でJSON化→JSでパース
    • 大きな文字列1回のコピー+V8の最適化済みJSON.parse
  • serde-wasm-bindgen:Rust構造体をJsValueへ直変換し返却
    • しかしJSとRustのメモリレイアウトが異なり、フィールド毎に逐次変換が必要
    • 小さなデータを大量に変換するため30%遅くなるケースが多発

ベンチマーク比較(1000回実行、1回あたりの平均μs)

| Fixture | JSON往復 | serde-wasm-bindgen | 差分 | |----------------|----------|--------------------|------------| | simple-table | 20.5 | 22.5 | 9%遅い | | contact-form | 61.4 | 79.4 | 29%遅い | | dashboard | 57.9 | 74.0 | 28%遅い |

TypeScriptへの全面移植とさらなる最適化

  • TypeScript移植:同じ6段階構成、WASM排除でV8ヒープ内で完結
  • ワンショットパース(1回のparse呼び出し)

| Fixture | TypeScript | WASM | 高速化倍率 | |----------------|------------|------|------------| | simple-table | 9.3 | 20.5 | 2.2x | | contact-form | 13.4 | 61.4 | 4.6x | | dashboard | 19.4 | 57.9 | 3.0x |

ストリーミング時の非効率性とインクリメンタルパース

  • LLMのストリーム出力ごとに全体再パース(O(N²))が発生
    • 例:1000文字を20文字ずつ50回→累積25,000文字分パース
  • 解決策:文単位インクリメンタルキャッシュ
    • 完了した文ASTはキャッシュ、未完了分のみ再パース(O(N))
    • 完了文は再パース不要、進行中のみ都度パース

ストリーム全体のコスト比較(全チャンク合計μs)

| Fixture | Naïve TS | Incremental TS | 高速化倍率 | |----------------|----------|---------------|------------| | simple-table | 69 | 69 | 変化なし | | contact-form | 316 | 122 | 2.6x | | dashboard | 840 | 255 | 3.3x |

WASM活用の適正領域と教訓

  • WASMが向くケース
    • 計算集約型でインターフェース回数が少ない処理(画像処理、暗号、物理シミュレーション等)
    • 既存C/C++ライブラリのブラウザ移植(例:SQLite, OpenCV)
  • WASMが不向きなケース
    • 構造化テキストをJSオブジェクト化する処理(パース自体は高速、データ転送が律速)
    • 小さな入力に対し頻繁に呼ばれる関数(境界コストが打ち消す)
  • 重要な知見
    • serde-wasm-bindgenの「直接オブジェクト返却」はコスト削減にならない
    • アルゴリズム改善(O(N²)→O(N))が言語最適化より効果大
    • WASMとJSはヒープ共有不可、常に変換コストが発生

まとめ

  • WASM活用時は「どこで何に時間がかかるか」事前プロファイルが必須
  • 境界コストが支配的な用途ではTypeScriptの方が有利
  • アルゴリズム設計の見直しが最大の高速化要因

Hackerたちの意見

ここでの本当の勝利は、RustよりもTSのことじゃなくて、O(N²)からO(N)へのストリーミング改善なんだよね。これは言語の選択とは関係なく、単独で3.3倍の改善になる。WASMの境界を排除するのは2~4倍だけど、実際にユーザーが感じるストリーミング中のレイテンシーに影響を与えるのはアルゴリズムの修正なんだ。タイトルはもっと面白いエンジニアリングを過小評価してると思う。
もっと誤解を招くクリックベイトだね。
そうだね、でもn²はちょっと誇張してるかも。一つ気づいたのは、各呼び出しの時間を計ってから中央値を使ってること。ため息。ブラウザでね。:/ JSエンジンにタイミング攻撃防御が組み込まれてるのに。
> タイトルはもっと面白いエンジニアリングを過小評価してると思う。クリックベイトを突き抜けてくれてありがとう。投稿は面白いけど、無駄にクリックベイトに引っかかって記事を読むのにはもう疲れたよ。
uvも同じだけど、誰もそのメッセージを受け取らないんだよね。みんな「Rust最高!」って思って、uvの利点が言語じゃなくてアルゴリズムだってことを無視してる。
O(N²)からO(N)にしたら3.3倍速くなったけど、その前に境界を排除して(wasmをJSに置き換えたら)2.2倍、4.6倍、3.0倍のスピードアップがあったよ(前のテーブルを見てみて)。どちらも「本当の勝利」ではないみたい。言語とアルゴリズムの両方が大きな違いを生んでるのが、最後のテーブルの最初の列でわかるよ。wasmに移行することで大きなスピードアップがあって、その上でアルゴリズムを改善したことでさらに大きなスピードアップが得られたんだ。
そうだね、アルゴリズムの修正がここでは大部分を担ってる。でも、そのパーサーを何百回も小さなストリーミングチャンクに対して呼び出すと、WASMの境界コストがすぐに積み上がっちゃうよ。同じことがC++をWASMにコンパイルした場合にも起こるだろうね。
それは間違ってないけど、その勝利はあんまり注目されないだろうね。クリックベイトとしては弱すぎる。
HNではAI生成のコメントはやめてください。
「このコードを言語Lから言語Mに書き換えたら、結果が良くなった!」って、当然だよね。絡まったり歪んだりしてたものを整理するチャンスだったし、知られている悪い決定を避けて、新しく発明されたより良いアプローチを適用できたんだから。だから、L = Mでも成り立つんだよ。スピードアップは言語にあるんじゃなくて、書き直しと再考にあるんだ。
それから、元のコードを見たことがない第三者が、TypeScriptのソリューションをRustに書き換えれば、さらに効果が出るね。
その通り。コードを同じ言語で書き直しても改善が見られるよね。
一般的には正しいよね。書き直しでコードを改善できるけど、新しい言語が良かった理由はちゃんとあるんだ。それは境界でのコピーを避けること。彼らはそのコストを測定したって言ってて、古いバージョンではほとんどの実行時間を占めてたみたい(具体的な数字は出してないけど)。そのコストは新しいバージョンでは全く存在しないのは、単に言語のおかげなんだ。
ある程度は正直だったと思うよ。スピードアップの一因が、C++で気づかなかった大きなバグをPythonが修正したことだって指摘してたし。追記:電話の誤字を直した。
ここにいる著者の一人だよ。一般的にはそうだけど、今回は時間が私たちに何がうまくいくかを教えてくれたわけじゃない。ローンチの数日前に、アーキテクチャが正しくないといううっとうしい感覚があって、それに加えて仮定をテストするための重い計測があったんだ。
これについてはしばらく言ってるんだけど(当たり前だと思ってた)、指摘するとよくダウンボートされるんだよね。
なぜOpen UIがWASMで何かしているって聞いたことがなかったのか不思議だった。新しい会社が、Open UI W3Cコミュニティグループが5年以上使っている非常に混乱を招く名前を選んだんだね。https://open-ui.org/ Open UIは、HTMLにポップオーバーやカスタマイズ可能なセレクト、呼び出しコマンド、アコーディオンを持たせるための標準グループなんだ。彼らは素晴らしい仕事をしているよ。
ところで、RustとJSの境界でオブジェクトをシリアライズする問題について、もう少し深く掘り下げてみたんだけど、serdeのアプローチはパフォーマンス的にあまり良くないことに気づいたんだ。それを改善する方法を探ってみたよ。ここに詳しく書いてる: https://neugierig.org/software/blog/2024/04/rust-wasm-to-js....
msgpackやbebopみたいなものを試してみた?
似たようなことが、C++からPython 1.4にバッチ処理のコードを移行したときに起こったよ(1997年の話)。バッチが約10倍速く終わるようになったんだ。最初は信じられなくて、実際に作業が行われているか確認し始めたよ。ちゃんとやってた。ポートは、Pythonを本番で使えるか試すために週末にやったもので、C++のコードは数ヶ月かけて書いたんだ。ポートは関数ごとにかなり直接的で、言語やライブラリの違いで簡単な方法がなかったところは行ごとにそのままだった。数人で一緒に一日かけてスピードアップの理由を探ったけど、コードを見ただけでは手がかりが得られなかったから、両方のバージョンをプロファイリングし始めたんだ。ポートがキャッシュキーを構築して比較するコードの中で、以前は知られていなかったバグを偶然修正していたことがわかった。小さな不具合のある関数を特定した後、C++のコードを理解するためにかなり頑張って勉強しなきゃいけなかった。バグの正確な内容は覚えてないけど、その手のバグはPythonで表現するのが難しいだろうなって思ったのを覚えてる。それが偶然修正された理由なんだ。すぐに残りのバックエンドをPythonに移行し始めたよ。ほとんどのものは遅くなったけど、あまり遅くはならなかった。なぜなら、バックエンドのほとんどがI/Oバウンドだったから。すぐにアルゴリズムの改善がずっと早くできることがわかって、最も遅いものの多くがこれまで以上に速くなったんだ。そして、最も重要なのは、私たち(ソフトウェア開発者)がかなり速くなったことだね。
同意するよ — ヘッドラインが本質を隠してる。アルゴリズムの複雑性の改善は、実装言語に関係なく今後のすべての入力に対して効果があるけど、WASMの境界での利点は一度きりのものだよ。ステートメントレベルのキャッシングの洞察は一般化できるのも注目すべき点だね。多くのパーサー周辺のホットパスは、メモ化なしで繰り返しプレフィックス/サフィックスマッチングを行うと同じO(N²)の罠に陥るから。
面白い話だね!パフォーマンスって、しばしば直感に反することが多いし、逆に直感的じゃないこともあるよね(例えばC++からPythonに移るとき)。科学であると同時に、アートでもあるんだよね。こういうパフォーマンスの作業が、バグやシステムに対する隠れた仮定を発見する手助けになったって話、めっちゃ聞いたことあるよ。
> 小さな不具合のある関数を特定した後、問題が何だったのか理解するためにC++のコードをかなり頑張って調べなきゃいけなかった。バグの正確な内容は覚えてないけど、そのバグのタイプはPythonで表現するのが難しいだろうなって思ったのは覚えてる。それが偶然修正された理由でもあるんだよね。完全に推測だけど、コピーコンストラクタが予想外の場所で呼ばれて、重要な経路に影響してるんじゃないかな。
僕の経験は全く逆だよ。特に過去に関わったプロジェクトの一つで、監視サービスのメイン言語にPythonが選ばれた時がそうだった。要するに、これは大失敗だったんだ。Pythonプロセスが全プログラムのメトリクスを収集して解析するだけで、低スペックのボックスの30-40%の処理能力を消費してた。結局、プロジェクトはもう少し続いたけど、パフォーマンスの影響を少なくするために色々な対策をしなきゃいけなかった。Cで書かれたオープンソースのツールといくつかのグルーコードに全部置き換えることも考えたけど、初期のプロトタイプは数MBのメモリを使うだけで、CPU負荷もほとんどなかったのに、最終的にはプロジェクト全体が終了した時に時間の無駄だと判断されたんだ。
Pythonの利点の一つは、遅すぎるから、間違ったアルゴリズムやデータ構造を選ぶとすぐに分かることだよ。複雑なことになると、まさにここでLLMが苦労するのを感じる。だから、最初のバージョンをPythonで作って、結果に満足して問題の複雑さに対して速度が妥当だと感じたら、Claude Codeに重要な部分をRustに移植してもらうんだ。
> 私たちはすぐに残りのバックエンドをPythonに移行し始めた。ほとんどのものは遅くなったけど、I/Oバウンドだったからあまり遅くはなかった。例えば、PythonやRubyがCやC++と同じくらい速くなったら面白いなと思う。もしそれを実現するために両方を修正できるなら、可能性があるのかな。でも、CやC++みたいな言語がないと難しいよね。今は「スクリプト」言語とコンパイル言語の間に変な壁があるし。
あなたはnumpyのベクトルアルゴリズムみたいな、Python用に高度に最適化されたアルゴリズムを使ったんじゃないかな?もっと良いコードを書くのは難しいと思うよ、少なくとも私はそう感じる。
そうだね、もしJS-WASMの境界を越えてデータをシリアライズしたりデシリアライズしたりしてるなら(実際にはWASMかどうかに関わらず、Webワーカー間で一般的に)、データのマシャリングコストが積み重なることがあるよ。ただ、マシャリングなしで境界を越えてメモリを共有する方法もあるんだ:TypedArraysとSharedArrayBuffers。TypedArraysを使うと、基盤となるメモリの所有権を一つのワーカー(またはメインスレッド)から別のワーカーにコピーなしで移すことができる。SharedArrayBuffersを使えば、複数のワーカーが同じ連続したメモリのチャンクを読み書きできる。ただし、JavaScriptの型の良さが失われて、基本的には生のバイトで作業することになるのが難点だね。イベントループからのレイテンシはまだあるけど、postMessageがマクロタスクとしてキューに入るから、たぶん10μsくらいのオーダーだと思う。でも、ノンブロッキングでコードを実行したいなら、これが代償だよ。
これが一番上のコメントになるべきだね。
Emscripten C++ wasmの視点から強く同意するよ。emscripten::valのラウンドトリップを最小限に抑えるのが重要なんだ。キャッシュは直線的なデータジオメトリ用に設計されるべきで、SharedArrayBuffersは大量データには最適だね。でも、非同期処理を表現できるのはJSだけだから、言語の境界でon_completionコールバックの設計が必要だね。
それに加えて強調しておきたいのは、「パース計算が速すぎてV8のJITがRustの利点を消す」ってだけじゃなくて、こういうシンプルで明確なデータ構造や変異は、変なevalパスやグローバルアクセスなしで、比較的簡単にネイティブ速度にJITされるってことだね。
これは例外なのか、それともRustが既に確立された存在になって「古い」ものになったから、人々が「Rustから離れる」話を共有したいと思っているのかな?Rustが理論的に(そして実践的に)素晴らしいという話じゃない記事を読むのは気にしないよ。
業界には常に新しいものを追い求めているセグメントがあるよね。そいつらの中には、なんか知らんけどZigが好きな奴も多い。でも、Rustにも本当に問題があるんだ。手動のメモリ管理は最悪だし、GCが高いと思ってる人もいるけど、malloc()とfree()はグローバルロックを取るからね!みんなパフォーマンスを左右する要素について全然間違った考え方をしてるんだ。そういう考え方が技術的なナンセンスに繋がってる。
この話は、WASMに向いてないアプリケーションから離れることについてだよ。Rustの話ではないんだ。
JSとWASMはメインのArrayBufferを共有してる。ArrayBufferヒープを使おうとするのは、すごくJavaScriptらしくないよね。だって、そうすると文字列やオブジェクトはなくて、ただインデックスとサイズのペアだけになるから。まあ、JavaScriptは破壊的変更には慣れてるし。Chromium 47と今を比べてみて。実際の整数を別の破壊的変更として追加したら、WASMはほとんど不要になるよ。