Edited at

2019年のWebAssembly事情

2年前に、「(Learn the Hard Way)nodejs-8でのWebAssembly自体を調べてみた」として、WebAssemblyについて記事にしました。

この記事は、2年前から変わった、2019年6月現在でのWebAssemblyの状況についてまとめたものです。


WAT: WebAssembly Text Format

2年前は、WebAssemblyのテキスト表現といえば、S式形式であり、フォーマット名は「WAST」と呼ばれていました。そして、ツールbinaryenの、wasm-as/wasm-disコマンドでバイナリ形式の.wasmファイルとテキスト形式の.wastファイルの変換を行いました。

2019年現在、WebAssemblyのテキスト表現は、「WebAssembly Text Format」通称WATとなっています。

大きく変わったのは、命令コード部分の表記法が、木構造のS式形式から、FORTH言語風のスタック操作を行う後置記法となった点です。このWAT形式のファイルの拡張子は.watとなります。

以下はWAT形式のコードの例です:


add.wat

(module

(func (export "add") (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add
return
)
)

(注: WAT形式では、命令の複数の引数は、スタックの底側から第一引数、第二引数、となります。)

一方で、引数を明記するS式表現もWATの"Folded Instructions"として、構文糖が仕様にあり、以前のwast形式のコードとの互換が残っています。

以下は、add.watと同じ結果となるS式形式のコードです:


add-folded.wat

(module

(func (export "add") (param $a i32) (param $b i32) (result i32)
(return (i32.add (local.get $a) (local.get $b)))
)
)

注: 2年前のWAST時代での命令get_locallocal.getに、set_locallocal.setになりました。(binaryenや後述するツールwabtでは、旧来のget_localset_localでも、互換解釈してくれるようです。)

そして、S式形式だったWASTの名は、WATモジュールをテストするScriptのファイル名として、割り当てられました。


ツールwabt

以前は、binaryenをテキスト表現のアセンブラとして使いました。

現在でもEmscriptenなどはbinaryenを使用します。

しかし、binaryenのヘルプメッセージがいまもwastのままだったりしていて、現在のWAT形式のためのコンパイラとしては、新しめのツール実装のwabtを使うのが良いようです。

wabtのコマンドには、wat2wasm, wasm2watなどがあります。使い方は以下のとおりです:



  • wat2wasm add.wat: モジュールファイルadd.wasmが生成される


  • wasm2wat add.wasm: (S式形式などの短縮表記を用いない)WAT形式で標準出力する


  • wat-desugar add.wat: S式形式などの短縮表記を用いないWAT形式で標準出力する


ECMAScript Module Integration Proposal

"ECMAScript Module Integration Proposal"(es-module integration)とは、ES6のimport文で、wasmモジュールファイルをimport対象にするための仕様提案です。現在はまだStage 2ですが、現行のnodejs-12では、フラグ付きで実行できる状況にあります。

この仕様は、jsコードから.wasmモジュールファイルをimportできようにするだけでありません。

WebAssemblyのimportモジュール名として、(名前でなく)URLを設定可能にする仕様でもあります。

つまり、ECMAScript/WebAssemblyランタイムでは、.wasmモジュールファイルからも.wasmモジュールファイルや.js(ES6)モジュールファイルを自動解決して利用させることができるようになります。


例1: wasmファイルをimportするwasmファイルをimportするjsファイルを実行する

先程のadd.wasmを使うsub.wasmは、以下のように記述できます:


sub.wat

(module

(import "./add.wasm" "add"
(func $add (param $a i32) (param $b i32) (result i32)))
(func (export "sub") (param $a i32) (param $b i32) (result i32)
local.get $a
i32.const -1
local.get $b
i32.mul
call $add
return
)
)

ここでは、importのモジュール名部分が、./add.wasmとなっている点がポイントです。

このファイルsub.watを使い、コマンドwat2wasm sub.watを実行し、sub.wasmを生成します。

注意点として、wat2wasm実行では、importしたファイルのリンクはされません。また、sub.watの変換時には、add.wasmファイルの存在も不要です。

このsub.wasmを呼び出すJSコードmain.mjsは以下のように記述できます:


main.mjs

import {sub} from "./sub.wasm";

console.log(sub(20, 10));

add.wasmsub.wasmmain.mjsを同じディレクトリに置きます。

この実行には、nodejs-12が必要で、オプション--experimental-modules--experimental-wasm-modulesの双方が必須です:

$ node --experimental-modules --experimental-wasm-modules main.mjs

(node:41782) ExperimentalWarning: The ESM module loader is experimental.
10


例2: jsファイルをimportするwasmファイルをimportするjsファイルを実行する

まず、以下のmath.mjsモジュールファイルを用意します:


math.mjs

export function sin(x) {return Math.sin(x);}

export function cos(x) {return Math.cos(x);}

このmath.mjsをimportして使うtan.watを以下のように記述します:


tan.wat

(module

(import "./math.mjs" "sin" (func $sin (param $a f64) (result f64)))
(import "./math.mjs" "cos" (func $cos (param $a f64) (result f64)))
(func (export "tan") (param $a f64) (result f64)
local.get $a
call $sin
local.get $a
call $cos
f64.div
return
)
)

同様にwat2wasm tan.watコマンドを実行し、ファイルtan.wasmを生成します。

このtan.wasmを呼び出すmain.mjsは以下のとおりです:


main.mjs

import {tan} from "./tan.wasm";

console.log(tan(1));

同様に、math.mjstan.wasmmain.mjsを同一ディレクトリに置き、以下のように実行します:

$ node --experimental-modules --experimental-wasm-modules main.mjs

(node:41782) ExperimentalWarning: The ESM module loader is experimental.
1.557407724654902


ES module integrationについての補足


  • WebAssembly側で定数をimportする場合は、globalを使います


    • 例: (import "./math.mjs" "PI" (global $pi f64))




  • .jsモジュールと同様に、複数からimportされた.wasmモジュールは1つです


    • wasmからexportしたmemoryがあれば、複数箇所から更新されうることになるでしょう



  • ダイナミックローディング用のJavaScript API WebAssembly.instantiate(buffer, imports)でのwasmモジュールの利用では、引数bufferがURL情報を含まないため、wasm内のURL importが機能しなくなります



    • importsに、{"./math.mjs": await import(new URL("./math.mjs", import.meta.url))} などを渡すことで対処できる。



  • 新しいAPIの WebAssembly.instantiateStreaming(response, imports)では、おそらくwasm側のURL importも機能するでしょう


    • 注: この第一引数はfetch APIResponseオブジェクトであり、fetch APiを持たない現状nodejsには存在しないメソッドです。




その他のWebAssembly extension proposals

WebAssembly extensionのProposal一覧は、以下のURLで列挙されています:

以下のものは、nodejs-12やwabtのwat2wasmで、オプション指定で有効化できるproposalです:



  • multi values (node: --experimental-wasm-mv, wat2wasm: --enable-multi-value): (result i32 i32)のように、関数やブロックで複数の値を返すことを可能にする仕様提案。JS APIには影響しない。


  • reference types (node: --experimental-wasm-anyref, wat2wasm: --enable-reference-types): wasm関数のポインタ利用で使うTableの利用方法を拡張する仕様提案。anyref型とref命令及びcast-else-end命令の追加、funcrefをlocal値で扱えるようにしcall_ref命令の追加、Tableの各種操作のWebAssembly命令の追加、など。


  • exception handling (node: --experimental-wasm-eh, wat2wasm: --enable-exceptions): try-catch-endthrow/ rethrow等のWebAssembly命令を追加する仕様提案。FunctionGlobalと同列のオブジェクトとして、Eventの導入もする。


  • sign extension operators (node: --experimental-wasm-se, wat2wasm: --enable-sign-extension): 現状はi32からi64の間のみ存在する符号拡張命令(i64.extend_i32_s, i64.extend_i32_u)を、型としては存在しない8ビット/16ビット整数に対して符号拡張を行うWebAssembly命令を追加する仕様提案。i32.extend8_sなど。


  • threads proposal (node: --experimental-wasm-threads, wat2wasm: --enable-threads): Workerなどで共有できるSharedArrayBuffer対応とそのアトミック系命令をWebAssemblyにも入れる仕様提案。(WebAssembly内でスレッドを作れるわけではない)


  • BigInt<->i64 conversion in JS API (node: --experimental-wasm-bigint): JS APIでBigInt型をWebAssemblyのi64にマッピングさせる仕様提案。memoryでのBigInt64ArrayBigUint64Arrayとの互換性も規定する。


  • Non-trapping Float-to-int Conversions (node: --experimental-wasm-sat-f2i-conversions, wat2wasm: --enable-saturating-float-to-int): f32->i32などでの数値型の切り詰めキャスト命令でのトラップ処理なしバージョンの仕様提案。これらの命令では、切り詰めでオーバーフローする場合はその数値型の最大値になる、などの違いがある。


  • bulk memory (node: --experimental-wasm-bulk-memory, wat2wasm: --enable-bulk-memory): memcpymemset相当のmemoryに対する一括操作のためのWebAssembly命令の追加の仕様提案。memory.copymemory.fillなどが追加される。


  • tail call (node: --experimental-wasm-return-call, wat2wasm: --enable-tail-call): 末尾呼び出しのための命令return_callの追加の仕様提案。


  • SIMD (node: --experimental-wasm-simd, wat2wasm: --enable-simd): SIMD用の128ビットのv128型の導入と、v128操作および、既存の演算命令の並列版(i8x16i16x8i32x4i64x2f32x4f64x2)の追加の仕様提案。数学関数の命令はこの仕様には含まれない。


  • Custom Annotation Syntax for the Wasm Text Format (wat2wasm: --enable-annotations): wasmモジュールに埋め込める任意のカスタムメタデータ記述のためのWAT構文の拡張の仕様提案。


V8: --wasm-math-intrinsics

WebAssemblyの命令セットには、sincosを始めとする数学関数が存在していません。

このため、WebAssemblyの中で数学関数を使うには、Mathオブジェクトの関数をimportして、そのimportした関数をcallして使う必要があります。

しかし、外部モジュールの関数をcallして結果を受け取るまでの実行コストは、WebAssemblyの計算命令やlocal変数アクセスと比べて、非常に高くなってしまいます。

このため、chromeやnode.jsのJavaScriptランタイムのv8では、WebAssemblyのimportしたfuntcionが、ビルトインのMathオブジェクトの関数オブジェクトそのものである場合、関数呼び出しのcallが実行されるのではなく、v8内で組み込み処理されるオプション--wasm-math-intrinsicsが実装されており、デフォルトで有効化されています。

つまり、この--wasm-math-intrinsicsの恩恵を得るには、上述のtan.watのためのmath.mjsの実装は、


math.mjs

//NOTE: --wasm-math-intrinsicsが機能しないモジュール

export function sin(x) {return Math.sin(x);}
export function cos(x) {return Math.cos(x);}

ではなく、


math.mjs

//NOTE: --wasm-math-intrinsicsが機能するモジュール

export const sin = Math.sin;
export const cos = Math.cos;

とする必要があります。ダイナミックロードでは、WebAssembly.instantiate(buffer, {"./math.mjs": Math})などを行うことでも機能させられます。

sincosを頻繁に呼ぶWebAssemblyコードでは、この2つを入れ替えて実行速度を比べることができ、後者のときに実行速度は格段に向上するでしょう。


FFTでベンチマーク

に、sincosを使うFFT実装を用いたベンチマークコードを置きました。

Float64Arrayを使ったループ版FFTのJavaScript実装と、それとほぼ同等の(最適化してない)WATモジュール実装、およびnodejs用とブラウザ用のWASMモジュールのベンチマークスクリプトをおいてあります。

ブラウザでは、

で実行でき、Webコンソール上に、console.time/timeEndによる実行時間が出ます。

browser
JS FFT
WASM with Math function
WASM with Wrapped function

node-12
873.279ms
890.830ms
1666.906ms

chrome-75
926.402099609375ms
898.839111328125ms
1920.876953125ms

firefox-67
747ms
944ms
1067ms

nodejsやchromeだと、Mathの関数を直接渡すのと、ラップした関数を渡すのとでは、2倍ほど差が出るようです。


付録A: WATコード例


コメント

WATのコメントは2種類あります:



  • (; ... ;) :ブロックコメント


  • ;; ...: 行コメント

行コメントはセミコロン2つ必須です。


local.tee

local.tee $aは、local.set $aして直後にlocal.get $aするのと同等です。

つまり、a = b = c = 0は、

   i32.const 0

local.tee $c
local.tee $b
local.set $a

と記述できます。


戻り値有り条件分岐式if-else-endの例

2引数の大きい方を返すmax(a, b)は、以下のようなコードとなります:


max.wat`

(module

(func (export "max") (param $a f64) (param $b f64) (result f64)
;; return (a < b) ? b : a
local.get $a
local.get $b
f64.lt
if (result f64)
local.get $b
else
local.get $a
end
return
)
)

条件式の結果をスタックに起き、結果の型指定(result 型)を持ったif命令を呼びます。戻り値がある場合のif命令は、else部分が必須です。


ループとブロックloop/blockの例

以下は、ループ版階乗fact(n)のWebAssemblyコードです:


fact.wat

(module

(func (export "fact") (param $n i32) (result i32)
(local $i i32)
(local $r i32)

;; r = i = 1
i32.const 1
local.tee $i
local.set $r
block $i-break loop $i-continue
;; if (i > n) break
local.get $i
local.get $n
i32.gt_u
br_if $i-break

;; r = r * i
local.get $r
local.get $i
i32.mul
local.set $r

;; i = i + 1
local.get $i
i32.const 1
i32.add
local.set $i

br $i-continue
end end
;; return r
local.get $r
return
)
)


制御命令loopblockは、brbr_ifによるジャンプの扱いが違います。loopではloopの次の位置に前方ジャンプし、blockではendの次の位置に後方ジャンプします。

return命令で脱出するループでない限りは、繰り返しと脱出のためにloopblockを組み合わせて使うことになるでしょう。命令がループのendに到達しても、自動でloopまで巻き戻るわけではない点は注意です(br $i-continueの行をコメントアウトしたwasmモジュールを実行させてみれば確認できるでしょう)。

loopblockのラベルは、実際にはその分岐命令からのジャンプ対象までの相対深さの整数値です。このコードでは、br_if $i-breakbr_if 1br $i-continuebr 0になります(wasm2watで確認できるでしょう)。このため同じラベルを、並列するループで使うことは可能です。

また、このコードのloopblockの順序は入れ替えても同じ結果が得られるコードになります。ただ、loopを内側にするほうが、nodejsでの実行は、若干早かったです(wasmモジュールの最適化などもあるので、誤差かもしれません)。


多重分岐br_table

おなじみFizzBuzzとして、3で割り切れるなら1、5で割り切れるなら2、15で割り切れるなら3、それ以外は0を返すfizzbuzz関数を多重分岐br_table命令で実装したWATコードです。


fizzbuzz.wat

(module

(func (export "fizzbuzz") (param $n i32) (result i32)
block $default
block $fizz
block $buzz
block $fizzbuzz
;; switch (n % 15) {...}
local.get $n
i32.const 15
i32.rem_u
br_table $fizzbuzz $default $default $fizz $default
$buzz $fizz $default $default $fizz
$buzz $default $fizz $default
end
i32.const 3
return
end
i32.const 2
return
end
i32.const 1
return
end
i32.const 0
return
)
)

br_tableでは、ブロックラベルを複数個指定できます。スタックトップの整数値一つに対し、0,1,2,と左から数えたラベルへとジャンプします。スタックトップ値が範囲外の場合(ラベル数以上、マイナス値、など)は、一番最後のラベルにジャンプします(このコードでは、余り14のとき、最後のラベル$defaultが採用される)。