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形式のコードの例です:
(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式形式のコードです:
(module
(func (export "add") (param $a i32) (param $b i32) (result i32)
(return (i32.add (local.get $a) (local.get $b)))
)
)
注: 2年前のWAST時代での命令get_local
はlocal.get
に、set_local
はlocal.set
になりました。(binaryenや後述するツールwabtでは、旧来のget_local
やset_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では、フラグ付きで実行できる状況にあります。
- 仕様: https://github.com/WebAssembly/esm-integration/tree/master/proposals/esm-integration
- 例: https://github.com/WebAssembly/esm-integration/blob/master/proposals/esm-integration/EXAMPLES.md
この仕様は、jsコードから.wasm
モジュールファイルをimportできようにするだけでありません。
WebAssemblyの**import
モジュール名として、(名前でなく)URLを設定可能にする**仕様でもあります。
つまり、ECMAScript/WebAssemblyランタイムでは、.wasm
モジュールファイルからも.wasm
モジュールファイルや.js
(ES6)モジュールファイルを自動解決して利用させることができるようになります。
例1: wasmファイルをimportするwasmファイルをimportするjsファイルを実行する
先程のadd.wasm
を使うsub.wasm
は、以下のように記述できます:
(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
は以下のように記述できます:
import {sub} from "./sub.wasm";
console.log(sub(20, 10));
add.wasm
、sub.wasm
、main.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
モジュールファイルを用意します:
export function sin(x) {return Math.sin(x);}
export function cos(x) {return Math.cos(x);}
このmath.mjs
をimportして使う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
は以下のとおりです:
import {tan} from "./tan.wasm";
console.log(tan(1));
同様に、math.mjs
、tan.wasm
、main.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
があれば、複数箇所から更新されうることになるでしょう
- wasmから
- ダイナミックローディング用の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 APIの
Response
オブジェクトであり、fetch APiを持たない現状nodejsには存在しないメソッドです。
- 注: この第一引数はfetch APIの
その他の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
-end
やthrow
/rethrow
等のWebAssembly命令を追加する仕様提案。Function
やGlobal
と同列のオブジェクトとして、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
でのBigInt64Array
やBigUint64Array
との互換性も規定する。 -
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
):memcpy
やmemset
相当のmemory
に対する一括操作のためのWebAssembly命令の追加の仕様提案。memory.copy
やmemory.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
操作および、既存の演算命令の並列版(i8x16
、i16x8
、i32x4
、i64x2
、f32x4
、f64x2
)の追加の仕様提案。数学関数の命令はこの仕様には含まれない。 -
Custom Annotation Syntax for the Wasm Text Format (wat2wasm:
--enable-annotations
): wasmモジュールに埋め込める任意のカスタムメタデータ記述のためのWAT構文の拡張の仕様提案。
V8: --wasm-math-intrinsics
WebAssemblyの命令セットには、sin
やcos
を始めとする数学関数が存在していません。
このため、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
の実装は、
//NOTE: --wasm-math-intrinsicsが機能しないモジュール
export function sin(x) {return Math.sin(x);}
export function cos(x) {return Math.cos(x);}
ではなく、
//NOTE: --wasm-math-intrinsicsが機能するモジュール
export const sin = Math.sin;
export const cos = Math.cos;
とする必要があります。ダイナミックロードでは、WebAssembly.instantiate(buffer, {"./math.mjs": Math})
などを行うことでも機能させられます。
sin
やcos
を頻繁に呼ぶWebAssemblyコードでは、この2つを入れ替えて実行速度を比べることができ、後者のときに実行速度は格段に向上するでしょう。
FFTでベンチマーク
に、sin
とcos
を使う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)
は、以下のようなコードとなります:
```wat: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コードです:
```wat: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
)
)
制御命令loop
とblock
は、br
やbr_if
によるジャンプの扱いが違います。loop
ではloop
の次の位置に前方ジャンプし、block
ではend
の次の位置に後方ジャンプします。
return
命令で脱出するループでない限りは、繰り返しと脱出のためにloop
とblock
を組み合わせて使うことになるでしょう。命令がループのend
に到達しても、自動でloop
まで巻き戻るわけではない点は注意です(br $i-continue
の行をコメントアウトしたwasmモジュールを実行させてみれば確認できるでしょう)。
loop
やblock
のラベルは、実際にはその分岐命令からのジャンプ対象までの相対深さの整数値です。このコードでは、br_if $i-break
がbr_if 1
、br $i-continue
がbr 0
になります(wasm2watで確認できるでしょう)。このため同じラベルを、並列するループで使うことは可能です。
また、このコードのloop
とblock
の順序は入れ替えても同じ結果が得られるコードになります。ただ、loop
を内側にするほうが、nodejsでの実行は、若干早かったです(wasmモジュールの最適化などもあるので、誤差かもしれません)。
多重分岐br_table
おなじみFizzBuzzとして、3で割り切れるなら1、5で割り切れるなら2、15で割り切れるなら3、それ以外は0を返すfizzbuzz
関数を多重分岐br_table
命令で実装した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
が採用される)。