Rustとwasm-packで極めるWebAssembly:TypeScriptとの高パフォーマンス統合

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

クイックスタート(5分)

ウェブベースの画像処理ツールで4Kアセットの処理時に動作が重くなり、Canvasのフレームレートが12 FPSまで低下しました。ピクセル処理をWebAssembly(Wasm)経由でコンパイルされたRustへ移行したところ、変換レイテンシが480ミリ秒から36ミリ秒へと大幅に短縮されました。ここでは、TypeScriptプロジェクト内で最初のRust Wasmモジュールをビルドして利用するための最小限のワークフローを紹介します。

1. ツールチェーンのインストール

Rustコンパイラツールチェーンとwasm-packビルドドライバをインストールします:

# Rustツールチェーンのインストール
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# wasm-packのインストール
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

2. Rustクレートの初期化

WebAssembly出力向けに設定された新規ライブラリクレートを作成します:

cargo new --lib wasm-blur-engine
cd wasm-blur-engine

Cargo.tomlにwasm-bindgenを追加し、crate-typeをcdylibに設定します:

[package]
name = "wasm-blur-engine"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2.95"

3. コンピュートカーネルの実装

src/lib.rsを開き、生のピクセルバッファを直接反復処理するインプレースのグレースケールフィルタを実装します:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn apply_grayscale(pixels: &mut [u8]) {
    for chunk in pixels.chunks_exact_mut(4) {
        let r = chunk[0] as f32;
        let g = chunk[1] as f32;
        let b = chunk[2] as f32;
        let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
        chunk[0] = gray;
        chunk[1] = gray;
        chunk[2] = gray;
    }
}

4. ビルドとTypeScriptへのインポート

モダンなESモジュールをターゲットにしてクレートをコンパイルします:

wasm-pack build --target web --out-dir ../frontend/src/pkg

コンパイルされたモジュールをTypeScriptアプリケーションにインポートして初期化します:

import init, { apply_grayscale } from './pkg/wasm_blur_engine';

async function processCanvas(ctx: CanvasRenderingContext2D, width: number, height: number) {
  await init();
  const imageData = ctx.getImageData(0, 0, width, height);
  
  // Wasm境界を越えてピクセルデータを直接変更
  apply_grayscale(imageData.data);
  
  ctx.putImageData(imageData, 0, 0);
}

詳細解説

メモリ境界の理解

シリアライズのオーバーヘッドは、WebAssembly統合における最大のパフォーマンス上の落とし穴です。WasmはJavaScript側でWebAssembly.Memoryとして表される分離されたリニアメモリバッファ内で実行されます。リッチなJSONペイロードや動的なJavaScriptオブジェクトを受け渡すには、文字列のエンコードとデコードが必要です。このシリアライズにかかるコストによって、Rustによって得られた素の実行速度のメリットはすぐに相殺されてしまいます。

wasm-bindgenを介して&mut [u8]のようなスライスを渡す場合でも、JavaScriptからWasmヒープへのバッファコピーと、その逆のコピーが発生します。50MBを超えるデータセットでのコピーオーバーヘッドを排除するには、Rustの構造体側で独自のメモリアロケーションを管理する必要があります。そうすることで、JavaScriptは生ポインタを使用してそのメモリスペースへ直接読み書きできるようになります。

ゼロコピーアーキテクチャ

Rustから生ポインタを公開することで、TypeScriptが型付き配列ビューをWasmヒープ上に直接マッピングできるようになります:

#[wasm_bindgen]
pub struct ImageBuffer {
    data: Vec<u8>,
    width: u32,
    height: u32,
}

#[wasm_bindgen]
impl ImageBuffer {
    pub fn new(width: u32, height: u32) -> Self {
        let size = (width * height * 4) as usize;
        Self {
            data: vec![0; size],
            width,
            height,
        }
    }

    pub fn get_ptr(&self) -> *const u8 {
        self.data.as_ptr()
    }

    pub fn box_blur(&mut self, radius: u32) {
        // self.data上で直接動作する高速マルチパスSIMDブラー
    }
}

TypeScript側では、初期化プロミスからリニアメモリバッファを取り出し、ポインタアドレスの上に型付きビューをインスタンス化します:

import init, { ImageBuffer, InitOutput } from './pkg/wasm_blur_engine';

let wasmInstance: InitOutput;

async function runOptimizedPipeline(canvas: HTMLCanvasElement) {
  if (!wasmInstance) {
    wasmInstance = await init();
  }
  
  const engine = ImageBuffer.new(canvas.width, canvas.height);
  const byteLength = canvas.width * canvas.height * 4;
  
  // Wasmリニアヒープ上のダイレクトなゼロコピービュー
  const wasmMemoryView = new Uint8ClampedArray(
    wasmInstance.memory.buffer,
    engine.get_ptr(),
    byteLength
  );
  
  engine.box_blur(5);
}

高度な活用法

Web WorkerへのWasm計算処理のオフロード

CPU負荷の高いタスクはブラウザのメインスレッドをブロックし、フレーム落ちやUIの応答性低下を引き起こします。Wasmの実行を専用のWeb Workerに移行することで、時間のかかる処理中もUIのカクつきを防ぐことができます。

// worker.ts
import init, { ImageBuffer, InitOutput } from './pkg/wasm_blur_engine';

let wasm: InitOutput;

self.onmessage = async (event: MessageEvent<{ buffer: ArrayBuffer; width: number; height: number; radius: number }>) => {
  if (!wasm) wasm = await init();
  
  const { buffer, width, height, radius } = event.data;
  const engine = ImageBuffer.new(width, height);
  
  // 入力ピクセルをWasmヒープにコピー
  const wasmView = new Uint8Array(wasm.memory.buffer, engine.get_ptr(), width * height * 4);
  wasmView.set(new Uint8Array(buffer));
  
  engine.box_blur(radius);
  
  // クローンせずにバッファの所有権をメインスレッドへ転送
  self.postMessage({ buffer }, [buffer]);
};

ヒープアロケーションとメモリ保持の管理

WebAssemblyのリニアメモリは拡張されるのみで、実行中にオペレーティングシステムへ自動的に縮小・返却されることはありません。アルゴリズムが4Kフレームを処理するために一時的に256MBのヒープを要求した場合、そのメモリフットプリントはページの存続期間中確保されたままになります。以下のベストプラクティスを念頭に置いてください:

  • レンダリングフレームごとに新しい構造体をアロケートするのではなく、確保済みのバッファインスタンスを再利用する。
  • コンポーネントのアンマウント時(ReactのuseEffectのクリーンアップ関数やVueのonUnmountedなど)に、生存期間の長いwasm-bindgenオブジェクトに対して明示的にengine.free()を呼び出す。
  • メモリリークの問題があるwee_allocのような非推奨のアロケータを避け、Rust標準のdlmallocを使い続けるか、短命な一時領域にはbumpaloのようなバンプアロケータを使用する。

実践的なヒント

1. 本番用バイナリの最適化

デフォルトのリリースビルドには、不要なデバッグシンボルやスタック巻き戻しテーブルが含まれています。Cargo.tomlのリリースプロファイルを調整することで、1.8MBのバイナリを120KB未満にまで削減できます:

[profile.release]
opt-level = "z"     # バイナリサイズを積極的に削減
lto = true          # クレート依存関係を跨いだリンク時最適化(LTO)
codegen-units = 1   # コンパイラの最適化パスを最大化
panic = "abort"     # ランディングパッドと巻き戻しテーブルを削除

コンパイル時にwasm-optを実行して、未使用の関数を削除し、クリティカルなループをインライン化します:

wasm-pack build --target web --release

2. プロファイリングとベンチマーク

wasm-pack build --devで作成されたデバッグバイナリでパフォーマンスを測定しないでください。最適化されていないRustビルドは、ループ展開の欠如や境界チェックのオーバーヘッドにより、ネイティブJavaScriptよりも最大15倍遅くなることがあります。必ずChrome DevToolsのPerformanceパネルやFirefox Profilerを使用して、リリースビルドのプロファイリングを行ってください。

3. ランタイム機能の検出

アプリケーションエンジンを初期化する前に、ブラウザのWebAssembly対応状況を検証します:

export function isWasmSupported(): boolean {
  try {
    if (typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function") {
      const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00));
      return module instanceof WebAssembly.Module;
    }
  } catch {
    return false;
  }
  return false;
}

Rustの決定論的なメモリ安全性とWebAssemblyを組み合わせることで、TypeScriptアプリケーションはブラウザ上でネイティブに近い計算スループットを発揮できます。

Share: