WebAssemblyは本当に速いのか? [数値計算編]


JS の関数を WebAssembly にすると計算が速くなると言われますが, そうでもないという説も見かけたので, 簡単に検証してみます. ここでは純粋な数値計算の速度に注目し, ファイルサイズ等の側面は検討対象から除外します. なお「[数値計算編]」とタイトルに付けましたが, 続編を書く予定はいまのところありません.

先行研究:

題材として「色々な言語で計算速度を比較してみた」という記事に倣い, Leibniz 級数の評価を取り上げます. 交代級数を愚直に計算することってそんなに頻繁にあるのかわかりませんが… また, WebAssembly はネイティブ実行に近い速度が出るのか, という観点から, ネイティブコードの速度と比較します.

[04-22 19:40] コードの誤りを訂正すると同時にループ数を 1e9 に増やしました.

ネイティブ実行

環境は Debian 10.3 on Win10 (WSL), Intel Core i7-6700 CPU @ 3.40GHz です.

C 言語

数値計算のベンチマークという観点からするとやはりネイティブの C 言語の速度が基準になると思うので, C 言語から始めます. なお元記事のコードから修正しています.

leibniz.c
# include <stdio.h>
int main(void){
  int i;
  double sum = 0.0;
  int signum = 1;
  double denom = 1.0;

  for (i = 0; i<=1e9; i++) {
    sum += signum / denom;
    signum *= -1;
    denom += 2.0;
  };
  printf("Ans:%.16f\n", 4.0*sum);
  return 0;
}

Rust

leibniz.rs
fn main() {
    let mut sum: f64 = 0.0;
    let mut signum = 1;
    let mut denom = 1.0;

    for _ in 0..=1_000_000_000 {
        sum += signum as f64 / denom;
        signum *= -1;
        denom += 2.0;
    }
    println!("Ans:{:.16}", 4.*sum);
}

JS (Node.js)

leibniz.js
var sum = 0.0;
var signum = 1;
var denom = 1.0;

for (var i = 0; i<=1e9; i++) {
    sum += signum / denom;
    signum *= -1;
    denom += 2.0;
}
console.log('%d',4.0*sum);

結果

この値は一回のみ実行した結果ですが, 何度か繰り返して誤差は下一桁が変動する程度であることを確認しました.

$ gcc --version
gcc (Debian 8.3.0-6) 8.3.0
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ gcc leibniz.c -o leibniz
$ time ./leibniz
real    0m2.596s
user    0m2.578s
sys     0m0.000s
$ gcc -O3 leibniz.c -o leibniz
$ time ./leibniz
Ans:3.1415926545880506

real    0m1.136s
user    0m1.125s
sys     0m0.016s
$ gcc -O3 -march=native leibniz.c -o leibniz
$ time ./leibniz
Ans:3.1415926545880506

real    0m1.133s
user    0m1.125s
sys     0m0.000s
$ rustc --version
rustc 1.42.0 (b8cedc004 2020-03-09)
$ rustc leibniz.rs
$ time ./leibniz
Ans:3.1415926545880506

real    0m36.107s
user    0m36.094s
sys     0m0.016s
$ rustc -C opt-level=3 leibniz.rs
$ time ./leibniz
Ans:3.1415926545880506

real    0m1.124s
user    0m1.094s
sys     0m0.031s
$ rustc -C opt-level=3 -C target-cpu=native leibniz.rs
$ time ./leibniz
Ans:3.1415926545880506

real    0m1.181s
user    0m1.156s
sys     0m0.031s
$ node --version
v12.16.2
$ time node leibniz.js
3.1415926545880506

real    0m1.989s
user    0m1.953s
sys     0m0.047s

最適化しても案外 C が遅いですね… Rust のが 4 倍ほど速いですが, なにか間違えたのかな. 最適化なしの Rust が一番遅く, 最適化ありの Rust が一番速かったです.

最適化後は C 言語, Rust ともに 1.13 sec 程度でした. Node は 75% 程遅いです.

ブラウザ上で実行

いま手元に Google Chrome がないので Mozilla Firefox 75.0 on Win10 でのみ試しました.

JS

HTML のレンダリング等の時間には興味がないので, ボタンをクリックすると計算を開始するようにします.

leibniz.html
<!DOCTYPE html>
<html>
  <head>
    <title>Leibniz Series</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>

  <body>
    <button id="start">Start!</button>

    <script>
      function leibniz() {
          var sum = 0.0;
          var signum = 1;
          var denom = 1.0;

          for (var i = 0; i<=1e9; i++) {
              sum += signum / denom;
              signum *= -1;
              denom += 2.0;
          }
          console.log('%f',4.0*sum);
      }

      let button = document.getElementById("start");
      button.addEventListener("click", () => {
          const startTime = performance.now()
          leibniz();
          const endTime = performance.now();
          console.log(endTime - startTime); 
      });
    </script>
  </body>
</html>

結果は 0.168 sec 1.660 sec 程度でした. 最適化した C 言語より 30% 45% 程度遅いだけですね. 怖いくらい速いです…

Rust (wasm)

src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace=console)]
    fn log(s: String);
}

#[wasm_bindgen]
pub fn leibniz() {
    let mut sum: f64 = 0.0;
    let mut signum = 1;
    let mut denom = 1.0;

    for _ in 0..=1_000_000_000 {
        sum += signum as f64 / denom;
        signum *= -1;
        denom += 2.0;
    }
    log(format!("Ans:{:.16}", 4.*sum));
}
index.html
<!DOCTYPE html>
<html>
  <head>
    <title>Leibniz Series</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>

  <body>
    <button id="start">Start!</button>

    <script type="module">
        import init, {leibniz} from '/pkg/rust_wasm.js';

        let button = document.getElementById("start");
        button.addEventListener("click", async () => {
            const startTime = performance.now()
            await init();
            leibniz();
            const endTime = performance.now();
            console.log(endTime - startTime); 
        });
    </script>
  </body>
</html>

結果は 0.035 sec 1.462 sec 程度でした. 最適化後の Rust とほぼ同じ速度が出ています (10% 程度遅い) JS よりは速く, ネイティブより 30% 程度遅いです.

なお初期化関数 init() を呼ぶ時間も計測されていますが, ネイティブの方でもバイナリの読み込みから始めて処理が終了するまでの時間を計っているため, これで可能な限り平等な測定になっていると考えています. ちなみに init を呼び終わった後で時間を計ると 0.015 sec 1.381 sec 程度でした.

結論

WebAssembly は確かに純粋な数値計算の速度自体 JS よりも 5 倍程度速く, ネイティブに迫るスピードが出ること 15% 程度速いがネイティブほどではないことが確認できました. ただし本記事のコードには整数および浮動小数点数の四則演算だけが含まれているので, それ以外の処理 (べき乗など) や関数呼び出し, メモリアクセスが多くなる状況ではまた結果が変わるかもしれません. 実際, 先行研究ではここまで wasm が JS に対して圧勝している結果はあまりなさそうです.

ちなみに Node.js で wasm を呼んだらどうなるんでしょうね? やってみようかと思いましたが体力が尽きたのでまた今度にします.