🌊

WasmをさわってJSとの速度比較をしてみた

2022/09/09に公開

モチベーション

  • 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側)でやること
  1. Rustの環境を構築
  2. Rustでコードを記述
  3. RustでWasmファイルを生成
JS側でやること
  1. Wasmファイルをfetch
  2. arrayBufferでバイナリ配列化する
  3. バイナリ配列をWebAssemblyのコードとして、インスタンス化
  4. 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情報まとめ
https://zenn.dev/highgrenade/scraps/63fa9fb0a3b982

参考記事

https://developer.mozilla.org/ja/docs/WebAssembly/Concepts
https://mixil.mixi.co.jp/people/12242
https://wasm-dev-book.netlify.app/hello-wasm.html
https://zenn.dev/takewell/articles/11b80090137dcc

Discussion