WebAssemblyとJavaScriptの処理速度の比較
リスト2と同じ処理をJavaScriptでも実装した図3のサンプル(p002-wasm-vs-js-web)で、WebAssemblyとJavaScriptの処理速度を比較してみます。このサンプルは、指定された計算回数で、WebAssemblyまたはJavaScriptの処理で円周率を計算して、計算結果と処理時間を画面に表示します。JavaScriptの実装は後述する3パターンを用意しました。
図3 WebAssemblyとJavaScriptで円周率を計算するサンプル(p002-wasm-vs-js-web)
CPU:Intel Core i5-6400、メモリ:16GBのPCを利用して、計算回数10,000,000回で図3のサンプルを実行すると、WebAssemblyとJavaScriptそれぞれの計算時間は表1の通りとなりました。JavaScriptは実装の仕方によりWebAssemblyと同等の速度の場合も、大幅に速度が遅くなる場合もありました。このような速度差が発生する原因を、以下でサンプルの実装内容とともに説明していきます。
| 計算方法 | 計算時間(ミリ秒、5回の平均値) |
|---|---|
| WebAssembly | 35.0 |
| JavaScript(JS1ボタン) | 165.6 |
| JavaScript(JS2ボタン) | 36.8 |
| JavaScript(JS3ボタン) | 4453.2 |
まず、このサンプルでは、時間のかかる計算処理を「Web Worker」でバックグラウンド処理するため、webpackの「workerize-loader」を利用します。workerize-loaderはリスト7のコマンドでインストールします。「--save-dev」は開発用のツールとしてインストールするオプションです。
npm install workerize-loader --save-dev
「WebAssembly」ボタンに対応した実装は、リスト8の通りです。
import worker from 'workerize-loader!./worker'; // ...(1)
const instance = worker(); // ...(2)
// WASM ...(3)
document.getElementById('start-wasm').addEventListener('click', async () => {
document.getElementById('answer').textContent = '計算中...';
document.getElementById('time').textContent = '測定中...';
const count = BigInt(document.getElementById('count').value)
const startTime = Date.now();
const value = await instance.calcPiUsingWasm(count); // ...(4)
const endTime = Date.now();
document.getElementById('answer').textContent = value;
document.getElementById('time').textContent = endTime - startTime;
});
(1)でWeb Workerで行う処理を記述したworker.js(後述)をworkerize-loaderで参照して、(2)で処理の実体instanceを生成します。この記述により、instanceのメソッドはWeb Workerで実行されるようになります。「WebAssembly」ボタン押下時のイベント処理(3)で、instance.calcPiUsingWasmメソッド(4)を呼び出して円周率の計算処理を実行します。(4)の前後の記述は、実行時間の測定や画面更新の処理です。
calcPiUsingWasmメソッドは、worker.jsにリスト9の通り実装します。(1)のexportキーワードは関数が公開されることを、また、asyncキーワードは非同期処理を行うことを表します。(2)でWebAssemblyのパッケージを動的にインポートして、(3)でcalc-piメソッドを実行します。
export async function calcPiUsingWasm(count) { // ...(1)
const { calc_pi } = await import ('p001-calc-pi'); // ...(2)
return calc_pi(count);// ...(3)
}
JavaScriptの処理についても、リスト8、9同様に実装します。まず、「JS1」ボタンで実行されるworker.jsの計算処理はリスト10の通りです。Rustの実装(リスト2)をほぼそのままJavaScriptに書き直しています。
export function calcPiUsingJS1(count) {
let i = 1;
let value = 1.0;
while (i <= count) { // ...(1)
const index = 2 * i - 1;
value = value
- 1.0 / (2 * index + 1)
+ 1.0 / (2 * (index + 1) + 1);
i += 1;
}
return value * 4.0;
}
リスト10では、引数countはBigInt、変数iはNumberなので、(1)でBigIntとNumberの比較が発生し、この比較が全体の処理速度に影響します。そこで「JS2」ボタンの処理(リスト11)では、(1)で引数のcountを先にNumberの変数nCountに変換して、以後の処理ではnCountを利用するようにしました。この場合、処理速度はWebAssemblyと同等になりますが、計算精度はNumber(64ビット浮動小数点数)になり、整数部を符号なし64ビットで計算していたRustとは、厳密には計算精度が異なります。
export function calcPiUsingJS1(count) {
const nCount = Number(count); // ...(1)
let i = 1;
let value = 1.0;
while (i <= nCount) {
const index = 2 * i - 1;
value = value
- 1.0 / (2 * index + 1)
+ 1.0 / (2 * (index + 1) + 1);
i += 1;
}
return value * 4.0;
}
Rustと同じ精度で処理が行えるよう、「JS3」ボタンの処理(リスト12)では、整数部分をすべてBigIntで計算します(「1n」や「2n」のように数値の後ろに「n」をつけるとBigIntになります)。この場合、BigIntの計算が多く発生して、処理速度が低下します。
export function calcPiUsingJS1(count) {
let i = 1n;
let value = 1.0;
while (i <= count) {
const index = 2n * i - 1n;
value = value
- 1.0 / Number(2n * index + 1n)
+ 1.0 / Number(2n * (index + 1n) + 1n);
i += 1n;
}
return value * 4.0;
}
今回実装したサンプルでは、JavaScriptのNumberで扱いきれない大きな数値を利用した大規模な計算を、Rustで実装されたWebAssemblyがJavaScriptより高速に行えました。一般にプログラムの処理速度は実装内容や環境で変わりうるため、WebAssemblyがJavaScriptより常に高速とは限りませんが、今回のサンプルは、WebAssemblyが速度面でより有利になる局面があることを示しています。
