RustとWebAssemblyでChainSolveを構築した理由
ChainSolveの計算エンジンにRustをWebAssemblyにコンパイルして採用した理由と、達成したパフォーマンス向上についての技術的深掘り解説。
ChainSolveの構築を開始した際、私たちには譲れない要件がありました。すべての計算がブラウザ内でネイティブに近いパフォーマンスで実行されること。サーバーのラウンドトリップなし。クラウドの依存関係なし。ユーザーのマシンからデータが流出しないこと。この要件がRustとWebAssemblyへ導きました。
パフォーマンス要件
ChainSolveではユーザーが潜在的に数千のブロックを持つ計算グラフを構築でき、各ブロックは入力に対して数学演算を実行し、その結果をダウンストリームのブロックに渡します。ユーザーが入力値を変更すると、ダウンストリーム全体のサブグラフが再計算され、リアルタイムで更新される必要があります。理想的には1つのアニメーションフレーム内(60fpsで16ms)で。
純粋なJavaScriptで実装した計算エンジンは、約200ブロック付近でフレームドロップが目立つようになり、限界に達しました。5,000以上のブロック/グラフという目標をサポートするには、少なくとも10倍のパフォーマンス向上が必要でした。
Rustを選んだ理由
WebAssemblyにコンパイルできる複数の言語を検討しました。C、C++、Go、Rust です。Rustを選んだ理由は3つあります。
ガベージコレクションなしメモリ安全性
Rustの所有権システムは、ガベージコレクターなしでコンパイル時にメモリ安全性を保証します。これはWebAssemblyにとって重要です。なぜなら、WasmのリニアメモリモデルはUse-After-Freeやバッファオーバーフローがお客様のタブをクラッシュさせるだけでなく、メモリ全体を暗黙的に破損させるためです。Rustはこれらのバグを構造的に排除します。
ゼロコストの抽象化
Rustの抽象化(ジェネリクス、イテレータ、パターンマッチング)は、Cで手で書いたのと同じ機械語にコンパイルされます。つまり、パフォーマンスペナルティを支払わずに、クリーンで表現力豊かなコードを書くことができます。
優れたWebAssemblyツールチェーン
wasm-packツール、wasm-bindgenクレート、およびより広範なRust-to-Wasmエコシステムは成熟しており、十分にドキュメント化されています。最小限のボイラープレートで、手動FFIグルーコードなしにRust関数をJavaScriptに公開できます。
アーキテクチャ概要
ChainSolve計算エンジンは3つのレイヤーで構成されています。
-
Step [counter(steps)] グラフ表現
計算グラフは隣接リストとしてトポロジカル順序付きで保存されます。グラフ構造が変更される場合(ブロックが追加、削除、または再配線される)、カーンのアルゴリズムを使用してトポロジカル順序を再計算します。これにより評価シーケンスが決定されます。
-
Step [counter(steps)] 評価エンジン
エンジンはトポロジカル順序をたどり、各ブロックを評価します。ブロックは共通の
evaluate(&self, inputs: &[Value]) -> Result<Value, EvalError>インターフェースを持つRustトレイトオブジェクトとして実装されます。これにより、エンジンを変更することなく新しいブロックタイプを追加できます。 -
Step [counter(steps)] JavaScriptブリッジ
ブリッジレイヤーは
wasm-bindgenを使用して、最小限のAPIをJavaScriptに公開します。create_graph()、add_block()、connect()、set_input()、evaluate()、およびget_results()です。複雑な型はJSONとしてシリアライズされ、数値データはシリアライズのオーバーヘッドを最小化するために生のf64配列として渡されます。
ベンチマーク
Wasmエンジンを同等の純粋JavaScriptの実装と3つのワークロードで比較しました。
| ワークロード | JS (ms) | Wasm (ms) | 高速化 |
|---|---|---|---|
| 100ブロック、リニアチェーン | 2.1 | 0.18 | 11.7x |
| 1,000ブロック、分岐グラフ | 34.2 | 2.4 | 14.3x |
| 5,000ブロック、複雑DAG | 412.0 | 22.1 | 18.6x |
グラフの複雑さが増すにつれて高速化が向上します。Wasmエンジンがキャッシュ局所性の向上とJavaScriptの動的型チェックの廃止から利益を得るためです。
Web Worker統合
計算エンジンはメインスレッドをブロックしないようにWeb Worker内で実行されます。通信パターンは簡潔です。
- メインスレッドがグラフ変異(ブロック追加、接続変更、入力更新)を構造化メッセージとしてWorkerに送信します。
- Workerは変異をWasmエンジンに適用し、再評価をトリガーします。
- 結果は
postMessage経由でメインスレッドに送り返されます。 - 大規模な結果セットの場合、
postMessageのシリアライズコストを回避するために、SharedArrayBufferとAtomicsを使用します。
このアーキテクチャでは、UIスレッドは計算によってブロックされることはありません。グラフが500ms要すれば、ユーザーはブロックのドラッグ、キャンバスのスクロール、インターフェースとの相互作用をジャンクなしで継続できます。
学んだ教訓
プロダクションWasmアプリケーションの構築により、複数の教訓を得ました。
-
Wasmバイナリサイズが重要です。 初期ビルドは2.4MBでした。LTO、
wasm-opt、デバッグシンボルの削除を有効にした後、340KB gzipに削減しました。初期読み込み時間のために1キロバイトが数えられます。 -
頻繁な小さな割り当てを避けてください。 Wasmのアロケータはネイティブアロケータよりもシンプルです。評価ごとの一時データにアリーナ割り当てを使用し、割り当てオーバーヘッドを60%削減しました。
-
コマンドラインではなく、ブラウザでプロファイルしてください。 パフォーマンス特性は、ネイティブRustとWasmで異なります。Wasmプロファイリングサポート付きのChrome DevToolsパフォーマンスパネルが私たちの主要な最適化ツールでした。
-
ブリッジの両側をテストしてください。
wasm-bindgenシリアライゼーション内のバグは微妙です。ネイティブRust(cargo test経由)とブラウザ内(wasm-pack test経由)の両方で実行される包括的なテストスイートを保持しています。
結論
RustとWebAssemblyはChainSolveの計算エンジンの正しい選択でした。メモリ安全性、ゼロコストの抽象化、およびブラウザでのネイティブに近いパフォーマンスの組み合わせにより、JavaScriptだけでは不可能であるリアルタイム視覚計算体験を提供できます。
Wasmを次のブラウザベースのアプリケーションで検討している場合、このポストが決定に有用なコンテキストを提供することを願っています。実際の結果を見たい場合は、app.chainsolve.co.ukで計算グラフの構築を試してみてください。