WasmをさわってJSとの速度比較をしてみた
モチベーション
- WebAssembly(Wasm)とは何かを知る
- 具体的な実装イメージはどんな感じか(WasmコンパイルをRustで行う場合)
- JSとの速度比較をする
WebAssembly(Wasm)とは
- Webブラウザ上で、JSよりももっと高速に実行できる技術
- 厳密には、Webじゃなくても動くし、アセンブリでもない
- Wasmという言語があるわけではなく、C, C++, Rust, Goなどの言語からWasmファイルを生成し、各環境で利用する(ブラウザじゃなくても動作する)
JSの上位互換として思われがちだが、Wasm自体は計算しか出来ないので、JSと協調させて動作させるWeb周辺技術という扱いが正しいです。
2022年現在では、メジャーなモダンブラウザはWasmをサポートしており、Google Meetのぼかし機能だったり、顔や手のトラッキング系のJSライブラリなど身近なところでWasmが利用されています。
他にも下記のような事例があります。
具体的な実装イメージ
Rust側(Wasm側)でやること
- Rustの環境を構築
- Rustでコードを記述
- RustでWasmファイルを生成
JS側でやること
- Wasmファイルをfetch
- arrayBufferでバイナリ配列化する
- バイナリ配列をWebAssemblyのコードとして、インスタンス化
- WebAssemblyインスタンスから Rustで記述した関数にアクセスして利用
js側のコード的記述は以下のようになる。
fetch(wasm)
.then((response) => response.arrayBuffer())
.then((bytes) => WebAssembly.instantiate(bytes, imports))
.then((results) => {
const { add, get_timestamp, rand } = results.instance.exports
console.log(add(1, 2))
console.log('get_timestamp()', get_timestamp())
console.log('rand', toUnit32(rand()))
})
WebAssembly.instantiateStreaming
を利用すると、
- arrayBufferでバイナリ配列化する
- バイナリ配列をWebAssemblyのコードとして、インスタンス化
の部分が省略でき、コード量も抑えられて良いです。
WebAssembly.instantiateStreaming(fetch(wasm), imports).then((results) => {
const { add, get_timestamp, rand } = results.instance.exports
})
JSとの速度比較
Wasmは速いと言われていますが、実際にJSと比較してどれくらいの速度差があるのか、performance.now()を利用して、実行時間を計測してみました。
JavaScriptでのループ
forで、1億回ループ処理
let res_for = 0
const startTimeFor = performance.now()
for (let i = 1; i <= 100000000; i++) {
if (i === 100000000) {
res_for = i
}
}
const endTimeFor = performance.now()
console.log('res_for', res_for)
console.log('js forでの1億回ループの経過ミリ秒', endTimeFor - startTimeFor)
whileで、1億回ループ処理
let res_while = 0
const startTimeWhile = performance.now()
let i = 1
while (i <= 100000000) {
i++
if (i === 100000000) {
res_while = i
}
}
const endTimeWhile = performance.now()
console.log('res_while', res_while)
console.log('js whileでの1億回ループの経過ミリ秒', endTimeWhile - startTimeWhile)
Wasmでのループ
Rustの元コード(10億回ループ)
pub fn heavy_loop() -> u32 {
let mut i: u32 = 0;
loop {
i += 1;
if i == 1000000000 {
return i;
}
}
}
Wasmを記述したJSのコード
WebAssembly.instantiateStreaming(fetch(wasm), imports).then((results) => {
const { heavy_loop } = results.instance.exports
const startTime = performance.now()
const res_wasm_loop = heavy_loop()
const endTime = performance.now()
console.log('res_wasm_loop', res_wasm_loop)
console.log('wasm 10億回ループの経過ミリ秒', endTime - startTime)
})
計測結果
js for 1億回 → 約71ミリ秒
js while 1億回 → 約113ミリ秒
Wasm loop 10億回 → 0ミリ秒以下(Wasmは10億ループしたが0ミリ秒。マイクロ秒の世界?)
Wasmに関しては計測がミスってる?と思って、色々処理を並べたりしましたが、0ミリ秒のままでした。
performance.now()は、ミリ秒単位でしか計測できないことだったり、Rust側の処理を重くすれば、計測時間が0.◯◯ミリ秒になっていたりするので、おそらく計測のミスは無いはずです。。(爆速すぎて不安)
※案の定、Wasmについて、コードにミスが有りました。。。
Rust側のコードがWasmへコンパイルする際に、ループ処理中で複雑な計算を行っていないせいかループ自体が省略される様な最適化が行われてしまっている様で、
竹内関数という再帰をぶん回して、たらい回しにする複雑計算処理をRust側、JS側の両方に実装することで最適化を防ぎ、その状態で再度計測しました。
↓竹内関数
const tarai = (x, y, z) => (x <= y ? y : tarai(tarai(x - 1, y, z), tarai(y - 1, z, x), tarai(z - 1, x, y)))
竹内関数を経由すると、ループ数が1億回ではブラウザが停止してしまっていたので、ループを1000000回にして計測し直しました。
修正後のJSのforとwhileのループ
// ループ数
const loop_num = 1000000
// 竹内関数
const tarai = (x, y, z) => (x <= y ? y : tarai(tarai(x - 1, y, z), tarai(y - 1, z, x), tarai(z - 1, x, y)))
console.log('---------- js loop(for) 処理計測 ----------')
const startTimeFor = performance.now()
for (let i = 1; i <= loop_num; i++) {
tarai(i + 6, i + 2, i)
}
const endTimeFor = performance.now()
console.log(`js forでの${loop_num}回ループの経過ミリ秒`, endTimeFor - startTimeFor)
console.log('---------- js loop(while) 処理計測 ----------')
const startTimeWhile = performance.now()
let j = 1
while (j <= loop_num) {
tarai(j + 6, j + 2, j)
j++
}
const endTimeWhile = performance.now()
console.log(`js whileでの${loop_num}回ループの経過ミリ秒`, endTimeWhile - startTimeWhile)
修正後のwasmのループ
#[no_mangle]
// フロントエンド側からの引数によりループ
pub fn heavy_loop(loop_num: u32) {
let mut i: u32 = 0;
loop {
i += 1;
tarai(i + 6, i +2, i);
if i == loop_num {
break;
}
}
}
// Rust側の竹内関数
fn tarai(x: u32, y: u32, z: u32) -> u32 {
if x <= y {
y
} else {
tarai(tarai(x - 1, y, z), tarai(y - 1, z, x), tarai(z - 1, x, y))
}
}
JS側の処理
const loop_num = 1000000
WebAssembly.instantiateStreaming(fetch(wasm), imports).then((results) => {
console.log('---------- wasm loop 処理計測 ----------')
const { heavy_loop, heavy_loop_rand } = results.instance.exports
const startTime = performance.now()
heavy_loop(loop_num)
const endTime = performance.now()
console.log(`wasm ${loop_num}回ループの経過ミリ秒`, endTime - startTime)
})
これらで下記のような結果になりました。
- js forでの1000000回ループの経過ミリ秒 1635.2000000029802
- js whileでの1000000回ループの経過ミリ秒 1630
- wasm 1000000回ループの経過ミリ秒 577.5
wasmは、他処理よりも約3分の1となりました。(もっともらしい値になってよかった。。)
今回は単純なループ処理で負荷をかけての計測のみだったので、複雑なことへの考慮は全く無いですが、Wasm側の処理が複雑になるにつれて、以下の問題と向き合う必要があります。
- WasmファイルをJS側でfetchする際の遅延問題
- Wasmで処理した値をJSで受け取る際のデータ型を一致させる問題
まとめ
Wasmに関して、処理速度がJSと比べて高速なので、時間がかかる複雑な計算処理には向いていますが、通常のWeb開発にはToo muchな部分もあったり、JSが十分速いので、なんでもかんでもWasmで処理にするのではなく、適材適所で利用する必要があります。
ただ、Chromeが実装中のSQLite+WebAssemblyの情報があり、まだまだ可能性を秘めている技術なので、今のうちに簡単にWasm入門しておくか、くらいの気持ちで触っておくのはありだと思いました。
↓個人scrap情報まとめ
参考記事
Discussion