Wasmをさわって JSとの速度比較をしてみた フロントエンド定例 2022/9/9

WasmをさわってJSとの速度比較をしてみた フロントエンド定例 2022/9/9

tanifumiya|2022年09月09日
フロントエンド

こんにちは、フロントエンドチームの @high_g_engineer です。
今週のフロントエンド定例の内容を記載します。

フロントエンド定例について、以前の記事(ランサーズのフロントエンドチームが取り組んでいること)でお伝えしたのですが、毎週金曜日に開催しており、実際の業務で取り組んでいることや気になった技術情報等をシェアしあう会になっています。

以下、今週の内容です。

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が利用されています。

他にも下記のような事例があります。

具体的な実装イメージ(WasmコンパイルをRustで行う場合)

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のループ

Rust側の処理

#[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に関して、処理速度が爆速で複雑な計算処理には向いていますが、通常のWeb開発にはToo muchな部分もあったり、JSが十分速いので、なんでもかんでもWasmで処理にするのではなく、適材適所で利用する必要があります。
ただ、Chromeが実装中のSQLite+WebAssemblyの情報があり、まだまだ可能性を秘めている技術なので、今のうちに簡単にWasm入門しておくか、くらいの気持ちで触っておくのはありだと思いました。

↓個人scrap情報まとめ
https://zenn.dev/highgrenade/scraps/63fa9fb0a3b982

参考記事

https://developer.mozilla.org/ja/docs/WebAssembly/Concepts

出来ることは計算だけ?「WebAssembly」は一体なにが新しいのか〜エンジニアが語る技術愛 #03〜


https://wasm-dev-book.netlify.app/hello-wasm.html
https://zenn.dev/takewell/articles/11b80090137dcc

 

次回の更新予定は、9/16(金)になります!

前回の定例内容はこちらから確認可能ですのでご興味いただければ下記のリンクから閲覧いただければと思います。

https://engineer.blog.lancers.jp/?s=フロントエンド定例