WebAssembly を触ってみる
WebAssembly (wasm) とは
MDN によると
WebAssembly は最近のウェブブラウザーで動作し、新たな機能と大幅なパフォーマンス向上を提供する新しい種類のコードです。基本的に直接記述ではなく、C、C++、Rust 等の低水準の言語にとって効果的なコンパイル対象となるように設計されています。
つまり、ブラウザ内に(JS の処理系とは別に?)wasm の処理系があり、その処理系は C、C++、Rust などの低水準言語がコンパイルし吐き出したバイナリを解釈実行する。そして、パフォーマンス面でも JS に勝るものがある…てな感じですかね。
webassembly.org を見てみると、どうやら以下の言語から wasm のバイナリを生成できるようです。
Zig で wasm
最近勉強している Zig で wasm ファイルを生成してみます。以下のコードを用意し、
export fn add(i: i32, j: i32) i32 { return i + j; }
以下のコマンドでコンパイルします。
$ zig build-lib math.zig -O ReleaseFast -target wasm32-freestanding -dynamic
するとカレントディレクトリに math.wasm
というファイルが出来上がるかと思います。これを以下のように JS 側から読み込めば、Zig 側で定義した関数を呼び出すことができます。
<script> fetch("math.wasm") .then(response => response.arrayBuffer()) .then(bytes => WebAssembly.instantiate(bytes)) .then(results => results.instance) .then(i => { console.log(i.exports.add(1, 2)); }); </script>
正しく動作すれば、コンソールに 3 が表示されるはずです。
wasm の中身を見てみる
wasm2wat
を使えば wasm ファイルを wat ファイルに変換できます。これを使って、math.wasm
の中を見てみましょう。
(module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) local.get 1 local.get 0 i32.add) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 1024)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (global (;4;) i32 (i32.const 66560)) (global (;5;) i32 (i32.const 0)) (global (;6;) i32 (i32.const 1)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "add" (func $add)) (export "__dso_handle" (global 1)) (export "__data_end" (global 2)) (export "__global_base" (global 3)) (export "__heap_base" (global 4)) (export "__memory_base" (global 5)) (export "__table_base" (global 6)))
Lisp みたいに S 式で構成されているんですね。こんなところで S 式を目にするとは結構意外でした。
さて、add 関数に関連する箇所だけを抽出してみます。
(module (func $add (type 1) (param i32 i32) (result i32) local.get 1 local.get 0 i32.add) (export "add" (func $add)))
MDN によれば、Module という単位が wasm のバイナリに対応するとのことなので、ルートノードが module になっています。module ノードの子ノードとして func ノードと export ノードがあり、func ノードで関数を定義し、export ノードで関数を JS で認識できるようにしています。
もう一つ注目する箇所があって、それが以下です。
local.get 1 local.get 0 i32.add
MDN によると wasm のスタックマシン上で実行されるとのことなので、上記の命令をすべてスタックを操作します。local.get 1
は第 2 引数をスタックにプッシュ、local.get 0
は第 1 引数をスタックにプッシュ、i32.add
はスタックから 2 値をポップし、加算してからスタックにプッシュします。
JS と wasm で簡単なパフォーマンス比較
JS と wasm の両方で選択ソートを実装してみて実行速度を計測してみます。
Zig 側の実装例は以下の通り。
export fn sort(head: [*]i32, len: i32) void { var i: usize = 0; while (i < len) : (i += 1) { var j = i; var k = j; while (j < len) : (j += 1) { if (head[j] < head[k]) { k = j; } } var tmp = head[i]; head[i] = head[k]; head[k] = tmp; } }
JS 側の実装は以下の通り。今回は要素数15000の乱数列に対して10回ソートした合計処理時間を計測しました。
<script> var data = undefined; fetch("sort.wasm") .then(response => response.arrayBuffer()) .then(bytes => WebAssembly.instantiate(bytes)) .then(results => results.instance) .then(i => { // array to sort data = new Int32Array(i.exports.memory.buffer, 0, 15000); // js console.log(measure(() => { sort(data); }, 10) + " ms"); // wasm console.log(measure(() => { i.exports.sort(data.byteOffset, 15000); }, 10) + " ms"); }); function sort(data) { for (let i = 0; i < data.length; i++) { let k = i; for (let j = i; j < data.length; j++) { if (data[j] < data[k]) { k = j; } } [data[i], data[k]] = [data[k], data[i]]; } } function measure(callback, times = 1) { let acc = 0; for (let n = 0; n < times; n++) { window.crypto.getRandomValues(data); const start = performance.now(); callback(); const end = performance.now(); acc += end - start; } return acc; } </script>
結果は以下の通り。wasm の方がおおよそ 2 倍のパフォーマンス向上が見られますね。
wasm : 1974.2000000029802 ms js : 3934.10000000149 ms
wasm は CPU intensive な処理に向いているとのことなのでこの結果も納得です。暗号処理、画像処理あたりにも向いているような気がしますので、これらは別の機会に検証したいです。