3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JS と Rust/WASM で同じ画像フィルタを走らせたら、ブラウザはどれくらい速くなったか

3
Last updated at Posted at 2026-05-05

気になったこと

「WASM は速い」という話を見るたびに、具体的に どのくらい 速いのかが気になっていました。マイクロベンチは見つかるけど、ブラウザで 1024×1024 の画像を 1 枚処理すると 体感で違うのか? 違うなら何倍? 違わないなら、境界はどこ?

同じアルゴリズム・同じメモリレイアウト・同じ RGBA バッファに対して、JavaScriptRust→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 つ:

  1. バイナリが小さくなる.wasm4781 バイト に収まっています。fetch のレイテンシを気にしないで済むサイズ
  2. デフォルトアロケータ(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. 最初の 1 回は JIT が暖まってない。最初の 1〜2 サンプルは捨てる(このアプリは 1 回ウォームアップしてから本計測)
  2. performance.now() の解像度。最近のブラウザだと spectre-mitigation で 5 μs 刻みにクランプされていることがある。実測値が 1ms 未満で安定しないときはこれが原因のことが多い
  3. 中央値を取る。平均だと GC のスパイクや CPU スロットリングで 1 本の外れ値に引っ張られる
  4. canvas の pixel ratiodevicePixelRatio > 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 のフォームから、採用系のお声がけは SOKUDANITプロパートナーズ にも登録しています。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?