TL;DR
- メモリは一度にアロケートしよう
- JavaScriptとWebAssemblyの間の値渡しには気を使おう
- JSは高速で、単純なループではWASM以上の速度が出るので、WASMの使い所はよくよく考えるべき
はじめに
1000^2級の画像の全ピクセルをループして、(簡単に言うと)RGBA値の最も大きい値を抽出する、という処理をブラウザ上で突然したくなりました。ピクセル数が1,000,000だと、RGBAなので配列長は4,000,000となります。ブラウザで扱いたくないサイズ感です。
ここで、①WASMで高速化、ダメなら②サーバーサイドで計算させる…という算段をして、とりあえずWASMを試してみました。色々チューニングした結果、ブラウザ上で現実的な速度が出ることがわかりました(数十ms)。
RGBA値の計算について
本記事では深く説明しませんが、今回やりたい処理は「下記式により求まる実数値の最大値を探す」というものです。
value = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)
TerrainRGBという標高値のエンコーディングです、詳しくは下記 https://qiita.com/Kanahiro/items/e22594b738655a189c1d#rgb%E5%80%A4%E3%81%AE%E6%A8%99%E9%AB%98%E6%8F%9B%E7%AE%97
WebAssemblyの実装
パフォーマンスチューニングの勘所は、引数や計算結果の渡し方です。 なおサンプルコードでは、WASMはRust+wasm-bindgenで書くものとします。
JSからWASMへの配列の渡し方
WASMへ要素数4,000,000の配列を関数の引数として素直に渡すと、値のコピーですごく遅くなります。下記のように渡すと高速です。
- JS側で、WASMに渡したい配列を用意する(ここでは画像のピクセル値の配列)
- WASM側で、JSから受け取る配列のサイズに合わせてを配列を初期化する(メモリをアロケートする)
- WASMからJSへ、WASM側で初期化した配列のポインタを渡す
- JS側で、WASMから受け取った配列のポインタをもとにWASM上のメモリのビューとして配列を初期化する
- 4の配列に、1で用意した配列をコピーする
WASMからJSへの計算結果の渡し方
JSからWASMへの配列の渡し方と考え方は同じです。
- WASM側で、JSから受け取る配列のサイズに合わせてを配列を初期化する(メモリをアロケートする)
- WASMからJSへ、WASM側で初期化した配列のポインタを渡す
- JS側で、WASMから受け取った配列のポインタをもとにWASM上のメモリのビューとして配列を初期化する
サンプルコード
mod utils;
use wasm_bindgen::prelude::*;
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
pub struct TerrainRgb {
rgba: Vec<u8>, // RGBA配列、JSから値を受け取る
elevations: Vec<f64>, // 標高配列、WASMでの計算結果が入る、JSへ渡す
pub pointer_to_rgba: *const u8, // JSからRGBA配列を参照するためのポインタ
pub pointer_to_elevations: *const f64, // JSから計算結果の標高配列を参照するためのポインタ
}
#[wasm_bindgen]
impl TerrainRgb {
#[wasm_bindgen(constructor)]
pub fn new(pixel_length: usize) -> Self {
let mut rgba: Vec<u8> = Vec::with_capacity(pixel_length); // 配列の初期化
unsafe { rgba.set_len(pixel_length) } // unsafeで配列長を確定してしまうことで添字アクセスを可能に
let pointer_to_rgba = rgba.as_mut_ptr(); // RGBA配列へのポインタ=メモリアドレスを取得
let mut elevations: Vec<f64> = Vec::with_capacity(pixel_length / 4);
unsafe { elevations.set_len(pixel_length / 4) }
let pointer_to_elevations = elevations.as_mut_ptr();
Self {
rgba,
elevations,
pointer_to_rgba,
pointer_to_elevations,
}
}
pub fn decode_elevations(&mut self) {
// RGBA値から標高値を計算し配列の値を更新
for i in 0..(self.rgba.len() / 4) {
self.elevations[i] = -10000.
+ 6553.6 * self.rgba[4 * i] as f64
+ 25.6 * self.rgba[4 * i + 1] as f64
+ 0.1 * self.rgba[4 * i + 2] as f64;
}
}
}
import wasm, { TerrainRgb } from './pkg/trial.js';
const image = new Image();
image.crossOrigin = '';
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const imageData = context.getImageData(
0,
0,
canvas.width,
canvas.height,
);
wasm().then((instance) => {
let start = performance.now();
const terrainrgb = new TerrainRgb(imageData.data.length);
const rgba = new Uint8Array(
instance.memory.buffer,
terrainrgb.pointer_to_rgba,
imageData.data.length,
); // ポインタをもとに、WASM側で初期化した配列の「ビュー」としてJS配列を初期化
rgba.set(imageData.data); // 画像の配列を丸々コピーする
terrainrgb.decode_elevations(); // RGBA値から標高値を計算する
const elevations = new Float64Array(
instance.memory.buffer,
terrainrgb.pointer_to_elevation,
imageData.data.length / 4,
); // ポインタをもとに、WASM側で初期化した配列の「ビュー」としてJS配列を初期化し計算結果を参照する
console.log('elevations', elevations);
console.log('wasm finish', performance.now() - start);
});
};
image.src = './terrain.png'; // 1400x1815の画像
結果
elevations Float64Array(2541000) […]
wasm finish 76.90000009536743
どちらの方向でも共通しているのは、WASM上にメモリを確保しJS側から参照するということです。 以上により、数十msで1,000,000ピクセルの画像の全ピクセルのループ処理が実現しました、速いですね!
異常言語JavaScript
ついにWASMをプロダクションで使えるかもなー、これでナウいフロントエンドえんじにゃーだZE とか思いつつ、じゃあJavaScriptだとどれだけかかるのか、ベンチマークしてみました。
const image = new Image();
image.crossOrigin = '';
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const imageData = context.getImageData(
0,
0,
canvas.width,
canvas.height,
);
let start = performance.now();
const jsDecodeElevation = (arr) => {
const elevs = new Float64Array(arr.length / 4);
for (let i = 0; i < arr.length / 4; i++) {
elevs[i] =
-10000 +
6553.6 * arr[4 * i] +
25.6 * arr[4 * i + 1] +
0.1 * arr[4 * i + 2];
}
return elevs;
};
const elevations = jsDecodeElevation(imageData.data)
console.log('elevations', elevations)
console.log('finish', performance.now() - start);
};
image.src = './terrain.png';
elevations Float64Array(2541000) […]
js finish 30.200000047683716
は??
素のJavaScriptの方が2倍速かったというオチ 軽量言語でネイティブ並の速度が出るJavaScriptはやっぱり異常