気になったこと
「WASM は速い」という話を見るたびに、具体的に どのくらい 速いのかが気になっていました。マイクロベンチは見つかるけど、ブラウザで 1024×1024 の画像を 1 枚処理すると 体感で違うのか? 違うなら何倍? 違わないなら、境界はどこ?
同じアルゴリズム・同じメモリレイアウト・同じ RGBA バッファに対して、JavaScript と Rust→WASM を順番に走らせるだけのページを作って、自分の目で確かめることにしました。
出来上がったのが wasm-image-filter です。画像をドロップ → フィルタを選ぶ → Run を押すと、JS と WASM それぞれの処理時間と speedup が出ます。
📦 GitHub: https://github.com/sen-ltd/wasm-image-filter
計測してみた結果(1024×1024・5 回中央値)
| Filter | JS | WASM | Speedup |
|---|---|---|---|
| Grayscale (BT.601) | 1.90 ms | 1.30 ms | 1.46× |
| Box Blur (radius 3) | 130.60 ms | 40.60 ms | 3.22× |
| Sobel edge | 20.10 ms | 9.50 ms | 2.12× |
手元の M2 MacBook Air + Chrome 安定版での数字です。実行のたびに JIT の暖まり具合で数 ms 揺れますが、おおよその 比 は安定して上の表のあたりに落ち着きます。
- Grayscale は 1 ピクセルに 3 回の乗算 + 1 回のシフトしかない、ほぼメモリ帯域律速の処理。ここでは V8 の JIT もかなり頑張っていて、差は 1.5 倍くらいしか付きません。
- Box Blur は計算量が効いてきます。半径 3 の畳み込みは 1 ピクセルあたり 49 サンプル。ここから先は「内側ループで何回算術演算をするか」がそのまま差に直結します。
- Sobel は 8 サンプル + 平方根で中間くらい。
「WASM だから 10 倍」ではなく、ループの深さに比例して差が開く というのが実際に動かしてみた感触でした。
構成: wasm-bindgen を使わなかった話
wasm-bindgen は便利なんですが、生成される JS グルーが「Uint8Array をコピーして → ポインタを計算して → 関数を呼んで → 戻ってきたコピーを読み直す」みたいな薄いラッパを全部の関数呼び出しに差し込みます。ベンチマークとしては、この薄いラッパがノイズになる。1 回の関数呼び出しで 100ms かかる処理なら無視できますが、grayscale のように 1ms を切り始めると、グルーコードの数十マイクロ秒が「WASM 側のコスト」として計上されてしまって、測りたいものがズレます。
そこで今回は wasm-bindgen を一切使わず、生の WebAssembly.instantiate で .wasm ファイルを読み込んで、linear memory に直接 RGBA を書き込む形にしました。JS 側のローダは実質これだけです:
let instance = null;
export async function loadWasm(url = './assets/filter.wasm') {
const res = await fetch(url);
const bytes = await res.arrayBuffer();
({ instance } = await WebAssembly.instantiate(bytes, {}));
}
function writeInto(ptr, src) {
const mem = new Uint8ClampedArray(instance.exports.memory.buffer, ptr, src.length);
mem.set(src);
}
function readOut(ptr, len) {
return new Uint8ClampedArray(instance.exports.memory.buffer, ptr, len).slice();
}
export function grayscale(buf) {
const { alloc_buffer, reset_heap, grayscale: run } = instance.exports;
reset_heap();
const ptr = alloc_buffer(buf.length);
writeInto(ptr, buf);
run(ptr, buf.length);
buf.set(readOut(ptr, buf.length));
}
これで JS → WASM の境界は「メモリへの書き込み」と「関数呼び出し」だけ。ベンチマークで問いたい「純粋な計算時間」以外の余計な層が挟まりません。
Rust 側: no_std + 自作バンプアロケータで .wasm を 4.7 KB に
Rust 側は #![no_std] で標準ライブラリを外しました。理由は 2 つ:
-
バイナリが小さくなる。
.wasmは 4781 バイト に収まっています。fetchのレイテンシを気にしないで済むサイズ - デフォルトアロケータ(dlmalloc)が要らない。WASM には OS がないので、stdlib が「システム mmap の代わり」として wasm32 用 dlmalloc を使います。でも今回やりたいのは「RGBA バッファを 1〜2 本、一時的に確保する」だけ
今回の用途なら バンプアロケータ 1 個で足りる、というのが正味のところです。reset_heap() を呼ぶと offset = 0 に戻るだけの単純実装。
struct Bump;
unsafe impl GlobalAlloc for Bump {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let align = layout.align();
let size = layout.size();
let cur = *CURSOR.offset.get();
let aligned = (cur + align - 1) & !(align - 1);
let end = aligned + size;
if end > HEAP_SIZE { return core::ptr::null_mut(); }
*CURSOR.offset.get() = end;
(HEAP.bytes.get() as *mut u8).add(aligned)
}
unsafe fn dealloc(&self, _: *mut u8, _: Layout) {}
}
64 MB の静的バッファを static HEAP として確保し、CURSOR はただのオフセット。deallocate は何もしない、reset_heap() でまとめてリセットする。
このパターンは WASM みたいに「処理の頭と尻尾が明確」な環境で非常によく合います。.wasm のサイズは wasm-bindgen を使った同等コードと比べて 10 倍以上小さい はずです(ランタイム・フォーマッタ・パニックハンドラなどが全部消える)。
Cargo.toml でサイズを絞るところも書いておきます:
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
strip = true
panic = "abort" が効いて、パニック用のアンワインダコードが丸ごと消えるのが大きいです。
Rust の SIMD ヒント
.cargo/config.toml に 1 行だけ足すと、Rust が WASM SIMD128 を使える命令セットとして扱ってくれます:
[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+simd128"]
box blur のような「大きなループの中で連続アドレスを読んで加算する」コードで、LLVM が自動ベクトル化する余地が出ます。手で v128 を書かなくても、フラグを立てるだけで自動ベクトル化が有効になる。今回、Box Blur で WASM 側が 3 倍以上速くなっているのはこのおかげです。
Docker でビルドする
手元に Rust ツールチェインを入れる気がない人(僕のことです)のために、ビルドは Docker 一発です:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
docker run --rm -v "$PWD/rust:/work" -w /work \
-e CARGO_TARGET_DIR=/work/target \
rust:1.90-alpine sh -c '
rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true
cargo build --release
'
cp rust/target/wasm32-unknown-unknown/release/wasm_image_filter.wasm assets/filter.wasm
rust:1.90-alpine は 200 MB 弱、初回だけ取ってくれば以降は 1.3 秒で再ビルド します。ビルド成果物 (.wasm) は repo にコミットしているので、npm run serve で即起動できる構成です。
測り方を間違えないために
ブラウザのベンチで最初に踏む罠:
- 最初の 1 回は JIT が暖まってない。最初の 1〜2 サンプルは捨てる(このアプリは 1 回ウォームアップしてから本計測)
-
performance.now()の解像度。最近のブラウザだとspectre-mitigationで 5 μs 刻みにクランプされていることがある。実測値が 1ms 未満で安定しないときはこれが原因のことが多い - 中央値を取る。平均だと GC のスパイクや CPU スロットリングで 1 本の外れ値に引っ張られる
-
canvas の pixel ratio。
devicePixelRatio > 1の MacBook で油断すると、表示上の「1024×1024」が内部で 2048×2048 になって 4 倍重くなる。今回のアプリはImageDataを直接操作しているのでここは無関係ですが、触ってる人は要注意
WASM がすべてを速くするわけではない
この 3 フィルタを 1 枚並べて分かるのは、「WASM を入れれば 10 倍」というのはフェアじゃない ということです。Grayscale は JS と WASM でほぼ互角で、理由は簡単で そもそも重くない から。V8 の JIT は、1 ピクセルあたり 3 乗算のような小さな inner loop なら WASM にかなり近いコードを吐きます。
逆に言うと、「重い inner loop」+「大量のピクセル」 という条件が揃った瞬間に WASM は存在価値を出します。今回 Box Blur が 3 倍以上速くなっているのも、「1 ピクセルあたり 49 サンプルの乗加算 + 除算」という inner loop が WASM + SIMD の土俵だから。
「どのフィルタから WASM に寄せるか」を事前に見積もるなら、「ホット関数内の算術演算回数 × 呼び出し回数」 を概算して、それが 10^8 を超え始めるあたりから WASM 化の効果が見えてくる、というのが今回遊んで分かった体感です。
使ってみるには
npm run serve + http://localhost:8080 でそのまま動きます。
- サンプル画像 (1024×1024) ボタン: 手元に画像がなくてもグラデ + 円 + 文字のテスト画像で走れる
- Runs: 3〜25 回の中央値を採れる
- Filter: Grayscale / Box Blur (半径 1〜10) / Sobel
リポジトリは MIT で https://github.com/sen-ltd/wasm-image-filter に置いています。assets/filter.wasm は prebuilt を同梱しているので、Rust 環境がなくても動きます。
これは何 #102
SEN 合同会社の「100 個以上の OSS ポートフォリオを作って営業ハブにする」プロジェクトのエントリ #205 です。一覧は sen.ltd/portfolio/ にあります。
お仕事のお問い合わせは https://sen.ltd のフォームから、採用系のお声がけは SOKUDAN と ITプロパートナーズ にも登録しています。
