📚

ごく単純なwasm(WAT)を書きたいときに見るやつ

に公開

https://zenn.dev/aya_koto/articles/1bbb9a567104d7

先日こんな記事を出したので、wasm(WAT)の書き方の備忘録も書いておこうと思います。
完全な説明ではないことは承知の上で、最低限この理解をしていれば書けるだろう、という方針で書いていきます。

事前説明: wasmとは?

そもそもwasmとは何?

正式名称はWebAssembly。長いのでwasmと略されがち。
コンパクトな低レイヤーの言語で、実行が速いらしいです。
別にJSを置き換えるためのものではなくて、速度を求める低レイヤー部分だけwasmを使うという共存が想定されています。

https://developer.mozilla.org/ja/docs/WebAssembly

WebAssembly は現代のウェブブラウザーで実行できるコードの一種です。
ネイティブに近いパフォーマンスで動作する、コンパクトなバイナリー形式の低レベルなアセンブリー風言語です。
WebAssembly は、 JavaScript を補完し、並べて実行するように設計されています。
WebAssembly は、ウェブプラットフォームに大きな影響を与えます。
複数の言語で記述されたコードをネイティブに近い速度でウェブ上で実行できるようになります。

テキスト形式表現(WAT)

wasmはバイナリー形式なので、そのままだと(ほとんどの人には)読めません。
そこで、wasmを人間が読んだり編集したりできるようにするためのテキスト表現(WAT)が存在します。
直接wasmを書くことになるならまあWATを書くことになるでしょう。

別にwasmにコンパイルできる言語が書ける人はそれを使えば良いでしょう。
wasmへのコンパイルが可能な言語には、C, C++, Rust, Go, Java, Kotlinなどがあるらしいです。一回あなたのメイン言語がwasmに変換可能か調べておくとよさそうです。

前提知識いろいろ

コメントアウト

ソースコードの各行について、;;以降はコメントアウトとみなされます。

スタックマシン

wasmの実行環境はスタックマシンとして定義されます。
つまり、実行開始時点では空のスタックがあり、各命令はスタックから値を読んだりスタックに値を積んだりします。

スタックは関数ごとに分離されていると考えてよいです。

もうちょっと厳密な話

厳密には、スタック自体はスレッド全体で共通です。
ただし、関数呼び出しごとに境界線が引かれ、その境界線を越えるコードはエラーとなります。

境界線自体は「スタックの何番目の後ろに境界線があるか」を別のスタックで持っておくような実装になってるってGeminiが言ってました、本当かは知りませんが賢い実装だな〜って思ったのでなんか本当な気がします

wasmが持つ型

wasmには数値型が4種類あります。文字列型とか真偽値型とかはないです。
整数型の符号の有無は型単位では決まっていなくて、命令によってどう扱うかが異なります。

  • i32: 32bit整数
  • i64: 64bit整数
  • f32: (符号あり)32bit浮動小数点数 (IEEE 754)
  • f64: (符号あり)64bit浮動小数点数 (IEEE 754)

booleanはi32の0(false)と1(true)で代用するのが一般的らしいです。

また、ベクトル型というのもあります。
これに関しては符号どころかどう扱うかも決まっていなくて、命令によって「32bit整数が4つ」とか「16bit整数が8つ」とかいろんな読み方をします。

  • v128: 128bitの整数の組とか浮動小数点数の組とか、128bit整数とか

他にexternrefとかfuncrefとかがありますが、今回の用途で使うことはなさそうなので無視します。

文法いろいろ

モジュールエクスポート

JS/TSで使うためには関数としてまとめたうえでエクスポートしないといけません。
ほぼ定型文なので記述例を提示しておきます。以下は有効なコードの例です。

add.wat
(module
    (func $add (param $a i32) (param $b i32) (result i32)
        local.get $a ;; [] -> [$a]
        local.get $b ;; [$a] -> [$a, $b]
        i32.add      ;; [$a, $b] -> [] -> [$a+$b]
    )
    (export "add" (func $add))
)
  • $addと"add"は適宜書き換えることができます。
    • JS/TS側で読み込むときの名前は"add"のほうが参照されます。

関数の定義

概ね下記の形になります。

(func $関数名 (param $引数名 型) (local $変数名 型) (result 型)
    ;; 実際の処理
)
  • (param $引数名 型)はその関数の引数です。
    • 0個以上をスペース区切りで書きます。
  • (local $変数名 型)はその関数内で使うローカル変数です。
    • 0個以上をスペース区切りで書きます。
    • JSだとvarが巻き上げで先に定義されますが、それを地でやれって言われている気がしますね
  • (result 型)はその関数の戻り値です。
    • 0個以上1個以下を書きます。
    • 複数値を返したい場合、(result 型 型)のように書きます。
  • 引数が複数ある場合、先にpopした値が後ろの引数に入ります。
  • 返り値が複数値になる場合、関数内スタックの頂上側が戻り値の頂上側として積まれます。
    • 境界線を抜き取るだけ、とも言えそう。

制御フロー

ブロックとかループとか条件分岐とかです。

;; ブロック
(block $foo
    ;; do something
)

;; ループ
(loop $bar
    ;; do something
)

;; if-else
(if
    (then
        ;; do something
    )
    (else
        ;; do something else
    )
)
  • ifの条件分岐は、if突入時点でスタックから値を1つpopすることで行います。
    • そのときpopされる値はi32である必要があります。
    • popされた値が0ならelse節を実行します。
    • さもなければthen節を実行します。
  • br $labelというものがあり、$labelがついた区間を飛ばします。
    • 関数についてbrすると、その関数を途中で抜けます。
    • blockについてbrすると、そのブロックを途中で抜けます。
    • loopについてbrすると、そのブロックの最初に戻ります。
      • JS/TSでいうcontinueですね
    • ifでも使えるってMDN Web Docsには書いてるんですけど詳細不明 有識者求む
  • br_if $labelというのもあります。
    • スタックから値を1つpopして、その値がi32かつ0でないときのみbrします。

命令いろいろ

たぶんここまで知っておけば単純なプログラムは書けるので、使いそうな命令に一通り目を通しておくことにしましょうか。
完全な表ではなくて、v128周りの命令を筆頭に色々端折っています。

定数・破棄・制御

命令 即値 pop push 説明
NUM.const <i0> i0: NUM [] [r0: NUM] i0をpush
drop [a0] a0を破棄
nop 何もしない
unreachable 正常ならここには来ないのでエラー発火
call <i0> i0: $label 関数i0を呼び出して実行

変数

  • localでは引数もローカル変数も同一の連番で呼ばれることに注意。
  • 他にtableとかelemとかmemoryとかあるらしいけど一旦見なかったことにします。
命令 即値 pop push 説明
global.get <i0> i0: $label [] [r0] グローバル変数i0の値をpush
global.set <i0> i0: $label [a0] a0をグローバル変数i0の値として代入
local.get <i0> i0: $label [] [r0] ローカル変数i0の値をpush
local.set <i0> i0: $label [a0] a0をローカル変数i0の値として代入

型変換

命令 即値 pop push 説明
i64.extend_i32_s [a0: i32] [r0: i64] a0を符号ありとみなしてi64に変換しpush
i64.extend_i32_u [a0: i32] [r0: i64] a0を符号なしとみなしてi64に変換しpush
i32.wrap_i64 [a0: i64] [r0: i32] a0の下位32bitをi32としてpush

数値比較

  • このカテゴリーの各命令は即値・pop・push・説明が共通しています。
    • 即値: なし
    • pop: [a0: (命令名に対応する型), a1: (命令名に対応する型)]
    • push: [r0: i32]
    • 説明: 「条件」を満たすならば1を、そうでなければ0をpush
  • iXXを扱う命令で被演算子の符号をあり/なしどちらとみなすかは、以下の法則に従います。
    • 命令名末尾が_s: 符号ありとみなす
    • 命令名末尾が_u: 符号なしとみなす
    • どちらでもない: 符号の有無にかかわらず同じロジックで対応できるのでそもそも区別しない
命令 条件
iXX.eqz a0 === 0
NUM.eq a0 === a1
NUM.ne a0 !== a1
fXX.lt a0 < a1
iXX.lt_u a0 < a1
iXX.lt_s a0 < a1
fXX.gt a0 > a1
iXX.gt_u a0 > a1
iXX.gt_s a0 > a1
fXX.le a0 <= a1
iXX.le_u a0 <= a1
iXX.le_s a0 <= a1
fXX.ge a0 >= a1
iXX.ge_u a0 >= a1
iXX.ge_s a0 >= a1

数値演算

  • このカテゴリーの各命令は即値・push・説明が共通しています。
    • 即値: なし
    • push: [r0: (命令名に対応する型)]
    • 説明: 「計算式」の計算結果をpush
      • "計算式"列では、a0をa_0、a1をa_1のように表記します。
  • iXXを扱う命令で被演算子の符号をあり/なしどちらとみなすかは、以下の法則に従います。
    • 命令名末尾が_s: 符号ありとみなす
    • 命令名末尾が_u: 符号なしとみなす
    • どちらでもない: 符号の有無にかかわらず同じロジックで対応できるのでそもそも区別しない
命令 pop 計算式
fXX.floor [a0: fXX] \lfloor a_0 \rfloor
fXX.ceil [a0: fXX] \lceil a_0 \rceil
fXX.trunc [a0: fXX] \mathrm{trunc}(a_0) ※整数に"0方向への丸め"
fXX.nearest [a0: fXX] \mathrm{round}(a_0) ※整数に四捨五入(たぶん最近接偶数丸め)
fXX.abs [a0: fXX] |a_0|
fXX.neg [a0: fXX] -a_0
fXX.sqrt [a0: fXX] \sqrt{a_0}
NUM.add [a0: NUM, a1: NUM] a_0 + a_1[1]
NUM.sub [a0: NUM, a1: NUM] a_0 - a_1[1:1]
NUM.mul [a0: NUM, a1: NUM] a_0 \times a_1[1:2]
fXX.div [a0: fXX, a1: fXX] a_0 \div a_1
iXX.div_s [a0: iXX, a1: iXX] \mathrm{trunc}(a_0 \div a_1)
iXX.div_u [a0: iXX, a1: iXX] \mathrm{trunc}(a_0 \div a_1)
iXX.rem_s [a0: iXX, a1: iXX] a_0 \bmod a_1
iXX.rem_u [a0: iXX, a1: iXX] a_0 \bmod a_1
fXX.min [a0: fXX, a1: fXX] \min(a_0, a_1)
fXX.max [a0: fXX, a1: fXX] \max(a_0, a_1)

ビット演算

  • このカテゴリーの各命令は即値・pushが共通しています。
    • 即値: なし
    • push: [r0: (命令名に対応する型)]
命令 pop 説明
v128.not [a0: v128] a0をbit反転したものをpush
iXX.and [a0: iXX, a1: iXX] a0とa1の論理積をpush v128にもある
iXX.or [a0: iXX, a1: iXX] a0とa1の論理和をpush v128にもある
iXX.xor [a0: iXX, a1: iXX] a0とa1の排他的論理和をpush v128にもある
iXX.clz [a0: iXX] a0の最上位に0が何bit連続するかを返す
iXX.ctz [a0: iXX] a0の最下位に0が何bit連続するかを返す

JS/TS側での読み込み

外部ファイル(.wasm)を読み込む

MDN Web Docsに記事があります。

https://developer.mozilla.org/ja/docs/WebAssembly/Guides/Text_format_to_Wasm

https://developer.mozilla.org/ja/docs/WebAssembly/Guides/Loading_and_running

簡潔に述べると以下の通り。

例えば、wasm側で"add"でexportしている関数は、以下のようなJS/TSコードで読み込めます。

load.ts
const { add } = (await WebAssembly.instantiateStreaming(fetch("add.wasm"))).instance.exports;

JavaScript内に直接埋め込む

先述の記事で触れたやつですが再掲します。

何らかの都合でJSファイルに埋め込みたい場合、wasmバイナリをBase64変換することで埋め込むことができます。
WATのBase64変換はwabtのOnline Demoでできます。

  • https://webassembly.github.io/wabt/demo/wat2wasm/ を開きます
  • ページ左側にWATをコピペします
  • その右の"BUILD LOG"って書いてあるところの隣に"BASE64"があるのでそれを押します
  • BASE64文字列が表示されるのでそれをコピーします。

コピーした文字列を使って、以下のようなコードを書きます。

inline.ts
const wasmBase64 = "(base64 string)";
const binary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
const module = new WebAssembly.Module(binary);
const instance = new WebAssembly.Instance(module);
const { add } = instance.exports;
短くしたい場合
inline_one-liner.ts
const wasmBase64 = "(base64 string)";
const { add } = new WebAssembly.Instance(new WebAssembly.Module(Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0)))).exports;
脚注
  1. たぶんi32とi64ではオーバーフローした分は捨てる挙動を取ると思われます(未検証) ↩︎ ↩︎ ↩︎

Discussion