Web制作・開発会社 プレスマンのスタッフブログ

PRESSMAN*Tech

NodeでWebAssemblyの速度を計る

以前から気になっていたWebAssemblyに触れてみると同時にどれ位速くなるのかを調べてみます。

WebAssemblyとは

効率的で速い

Wasmスタックマシンは、サイズとロード時間効率の良いバイナリ形式でエンコードされるように設計されています。 WebAssemblyは、幅広いプラットフォームで利用できる共通のハードウェア機能を利用して、ネイティブスピードで実行することを目指しています。

-- WebAssemblyホームページより

Javascriptだとスクリプト言語特有の遅さがあるのでバイナリ形式のファイルにして(つまりアセンブリ言語として)実行することで高速化しようというものですね。

ビルド環境を整える

今回はC言語で書いたものをビルドしてwasmを作成するので、MDNのページemscriptenのページを参考にemccコマンドが使用できるようにします。
私のマシンはmacOSなので以下のようにしました。

$ cd ~
$ git clone https://github.com/juj/emsdk.git
$ cd emsdk
$ ./emsdk install latest
$ ./emsdk activate latest
$ source ./emsdk_env.sh

~/.bashrcに以下を追記

source ~/emsdk/emsdk_env.sh > /dev/null

計測プログラム

今回は定番のフィボナッチ数を求めるコードでネイティブJSとWebAssemblyの実行時間の比較を行います。
それぞれに関数の再帰呼び出し版と単純ループ版を用意しています。

jsFib.js

jsFibRcsv = n => {
    switch (n) {
        case 0:
        case 1:
            return n;
        default:
            return jsFibRcsv(n - 1) + jsFibRcsv(n - 2);
    }
};

jsFibLoop = n => {
    let fn = 0, fnm = 0; // F(n), F(n-1)

    for (let i = 0; i <= n; i++) {
        switch (i) {
            case 0:
                break;
            case 1:
                fn = 1;
                break;
            default:
                fn ^= fnm;
                fnm ^= fn;
                fn ^= fnm;
                fn += fnm;
                break;
        }
    }

    return fn;
};

cFib.c

#include <emscripten.h>

unsigned long cFibRcsv(unsigned int n)
{
    switch (n) {
        case 0:
        case 1:
            return n;
        default:
            return cFibRcsv(n - 1) + cFibRcsv(n - 2);
    }
}

unsigned long cFibLoop(unsigned int n)
{
    unsigned long fn = 0, fnm = 0;

    for (int i = 0; i <= n; i++) {
        switch (i) {
            case 0:
                break;
            case 1:
                fn = 1;
                break;
            default:
                fn ^= fnm;
                fnm ^= fn;
                fn ^= fnm;
                fn += fnm;
                break;
        }
    }

    return fn;
}

C言語のソースをWebAssemblyにビルド

emccコマンドでwasm形式にビルドします。cFib.cのあるディレクトリで下記のコマンドを実行します。

emcc cFib.c -O2 -o cFib.js \
    -s WASM=1 \
    -s ASSERTIONS=1 \
    -s EXPORTED_FUNCTIONS='["_cFibRcsv", "_cFibLoop"]' \
    -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
    -s BINARYEN_ASYNC_COMPILATION=0

実行後はcFib.jscFib.wasmが作成されているはずです。

メインの処理を作成

後ほどAWS Lambdaで実行することも考えて以下のようにしました。ファイル名はindex.jsです。

'use strict';

const NUM = 40;

exports.handler = async () => {
    require('./jsFib');
    const Module = require('./cFib'),
        cFibRcsv = Module.cwrap('cFibRcsv', 'number', ['number']),
        cFibLoop = Module.cwrap('cFibLoop', 'number', ['number']);
    let time = [], diff = [], res = 0;;

    console.log('### Recursive call Fibonacci ###');
    console.log('  -- Javascript --');
    time = process.hrtime();
    res = jsFibRcsv(NUM);
    diff = process.hrtime(time);
    const jsRcsvTime = (diff[0] * 1e3 + diff[1] * 1e-6).toFixed(6);
    console.log(`    Res : ${res}`);
    console.log(`    Time: ${jsRcsvTime} ms`);

    console.log('  -- Webassembly(C) --');
    time = process.hrtime();
    res = cFibRcsv(NUM);
    diff = process.hrtime(time);
    const cRcsvTime = (diff[0] * 1e3 + diff[1] * 1e-6).toFixed(6);
    console.log(`    Res : ${res}`);
    console.log(`    Time: ${cRcsvTime} ms`);

    console.log('### Loop Fibonacci ###');
    console.log('  -- Javascript --');
    time = process.hrtime();
    res = jsFibLoop(NUM);
    diff = process.hrtime(time);
    const jsLoopTime = (diff[0] * 1e3 + diff[1] * 1e-6).toFixed(6);
    console.log(`    Res : ${res}`);
    console.log(`    Time: ${jsLoopTime} ms`);

    console.log('  -- Webassembly(C) --');
    time = process.hrtime();
    res = cFibLoop(NUM);
    diff = process.hrtime(time);
    const cLoopTime = (diff[0] * 1e3 + diff[1] * 1e-6).toFixed(6);
    console.log(`    Res : ${res}`);
    console.log(`    Time: ${cLoopTime} ms`);

    // 結果を表形式で出力 console.table()使いたい...
    const maxLen = Math.max((jsRcsvTime + '').length, (cRcsvTime + '').length, (jsLoopTime + '').length, (cLoopTime + '').length),
        rcsvSU = ((jsRcsvTime - cRcsvTime) / jsRcsvTime * 100).toFixed(2) + '%',
        loopSU = ((jsLoopTime - cLoopTime) / jsLoopTime * 100).toFixed(2) + '%';
    console.log("~~Result (Unit: ms)~~\n" +
        `|                | ${'js'.padEnd(maxLen)} | ${'c'.padEnd(maxLen)} | speed up |\n` +
        `| Recursive call | ${(jsRcsvTime + '').padStart(maxLen)} | ${(cRcsvTime + '').padStart(maxLen)} | ${rcsvSU.padStart(8)} |\n` +
        `| Loop           | ${(jsLoopTime + '').padStart(maxLen)} | ${(cLoopTime + '').padStart(maxLen)} | ${loopSU.padStart(8)} |`);
};

また、ローカル環境での実行用に以下のファイル(doIndex.js)も作っておきます。

require('./index').handler();

実行してみる

この時点でカレントディレクトリが以下のようになっていることを確認します。

.
├ cFib.c
├ cFib.js
├ cFib.wasm
├ doIndex.js
├ index.js
└ jsFib.js

それでは実行してみます。

$ node doIndex.js
### Recursive call Fibonacci ###
  -- Javascript --
    Res : 102334155
    Time: 1256.640462 ms
  -- Webassembly(C) --
    Res : 102334155
    Time: 743.958196 ms
### Loop Fibonacci ###
  -- Javascript --
    Res : 102334155
    Time: 0.091494 ms
  -- Webassembly(C) --
    Res : 102334155
    Time: 0.019313 ms
~~Result (Unit: ms)~~
|                | js          | c           | speed up |
| Recursive call | 1256.640462 |  743.958196 |   40.80% |
| Loop           |    0.091494 |    0.019313 |   78.89% |

index.jsの定数NUMの値を10,15,16,20,30,40,50にしてそれぞれ計測した結果は下記のとおりです。

再帰呼出し

Javascript (ms)C (ms)Speed UP (%)
100.0816790.195937-139.89
150.1655040.196247-18.58
160.2205440.2005639.06
204.4727080.26599994.05
3015.5766656.37927059.05
401248.604396689.09005544.81
50163191.78934783956.83497148.55

単純ループ

Javascript (ms)C (ms)Speed UP (%)
100.0511940.01029579.89
150.0421620.00867879.42
160.0541820.00926682.90
200.0466580.01078776.88
300.0582630.01417575.67
400.0641110.01657574.15
500.0634270.01590774.92

まとめ・考察

WebAssembly化しても相変わらず関数呼び出しのコストは高いみたいですね。まあ、関数定義はコンパイルされたcFib.js内で行っている(=関数呼び出しはJSを経由する)ので当然でしょうか。再帰呼び出しの15以下を見るとネイティブのJSのほうが速いことから、もしかしたらネイティブJSの関数呼び出しよりWebAssemblyの関数を呼び出すほうがコストが高いのかもしれません。

一方、単純ループの方は明快ですね。一律70〜80%の速度向上となっています。

おまけ:AWS Lambdaで計ってみる

NUM=30で実行してみました。メモリは1024MB、タイムアウトは1分にしてます。

ログ出力より抜粋

~~Result (Unit: ms)~~
|                | js        | c         | speed up |
| Recursive call | 23.251446 |  7.433587 |   68.03% |
| Loop           |  0.069267 |  0.015754 |   77.26% |

いい感じですね。2回め実行してみましょう。

~~Result (Unit: ms)~~
|                | js        | c         | speed up |
| Recursive call | 21.229039 |  7.681361 |   63.82% |
| Loop           |  0.005988 |  0.011372 |  -89.91% |

??? 🤔 ???

Lambdaでは関数インスタンスを再利用、つまりrequireされた内容はキャッシュするらしいのですが、上の結果を見る限り、

  • ネイティブJSの関数はキャッシュされているっぽい
    • ただし、再帰呼出ししている場合はキャッシュされない
  • WebAssemblyの関数はキャッシュされない

Lambdaだと使いどころの見極めがより大変そうですね・・・

table { border-spacing: 0; } table th, table td { border-bottom: 1px solid rgba(0,0,0,.54); padding: 12px; } table th:first-of-type, td:first-of-type { padding-left: 24px; } table th:last-of-type, td:last-of-type { padding-right: 24px; }