WASMってモジュールとコンポーネントがあって初見じゃよくわからないですよね。 ということでこの辺りの思考整理をする記事です。
諸概念の整理
WASMモジュールとWASMコンポーネント?
まず、WASMを学ぼうとした時に「モジュール」と「コンポーネント」という一見似た概念が登場します。
WASMコンポーネントは後述するWASMモジュールの仕様上の課題を解決するために提案されたものになります。
主要なブラウザはネイティブサポートしていませんし、利用できるランタイムも執筆時点で限定的なものになっています。
ということで、WASMモジュールがいわゆるWASMと聞いた時に現時点では連想するものになるかと思いますので、まずはWASMモジュールについて触れたいと思います。
WASMモジュール
WASMモジュールはWebAssembly コア仕様に基づくWebAssemblyのバイナリになります。(.wasmファイル) ブラウザやその他ランタイムはこのバイナリが実行可能なわけです。
こちらはWebAssemblyコア仕様のバージョン1です。これらの機能は主要なブラウザでは全て利用可能です。
こちらはWebAssemblyコア仕様のバージョン2です。
こちらはWebAssemblyコア仕様のバージョン3です。
バージョンごとの新しいfeatureについては触れません。対応状況などはこちらから確認できます。
WASMモジュールの限界
WASMといえば従来想定されていた用途の他に、JVMのような「Write Once, Run Anywhere」な可能性として注目されているという話を耳にするかと思います。 しかし、それにはWASMモジュールは下記の点で非力でした。
1. 型の表現が限定的である
WebAssembly コア仕様(v1)を参照すると、利用できる値型はi32、i64、f32、f64の4種類のみです。 私たちが日頃よく利用する文字列、リスト、配列、列挙型、構造体などは利用できません。これらは上記の型で表現を代替する必要がありました。
2. 相互運用性
(1)の課題から、WASMモジュールとそれを呼び出す側でメモリレイアウトを気にしないといけないことも難点であることがわかります。
例えば、RustとCでは文字列を扱う型でも全くメモリレイアウトが異なります。実装されたWASMモジュールはどのようなメモリレイアウトで文字列をintegerに変換されたことを期待するのでしょうか?辻褄を合わせないといけません。 戻り値の解釈も然りです。
この結果、WASMモジュールでは言語間の相互運用性が現実的にかなり厳しいものとなっていました。
因みに、WASMモジュール間の相互運用性もまた課題でした。 WASMモジュールAからBを直接呼ぶことはできないので、このような連携はホスト言語を介する必要があります。
WASMコンポーネント
そこで、WASMコンポーネントの出番です。 WASMコンポーネントは、WASMコア仕様を拡張する独立した仕様です。
WASMコンポーネントでは、WIT(Wasm Interface Type)と汎用的なインターフェース記述言語を用いてインターフェースとワールドを定義します。
ワールドは聞きなれない単語ですが、このWASMコンポーネントがどのインターフェースをimportして、どのインターフェースをexportするのかを記述するものです。
world user-service {
import wasi:postgresql/client; // データベースアクセス
import wasi:http/outgoing-handler; // 外部API呼び出し
export user-management; // ユーザー管理機能を提供
export wasi:http/incoming-handler; // HTTPエンドポイント
}
インターフェースは下記のように、機能ごとに「関数」と「型」の集合を定義します。
interface user-management {
record user {
id: u64,
name: string,
email: string
}
create-user: func(name: string, email: string) -> result<u64, string>;
get-user: func(id: u64) -> result<option<user>, string>;
}
上記の例ではワールドに2つのimportがありますので、これらから定義されるインターフェースに準拠する必要があります。
ホスト言語のコードにより実装することもできますが(例えば bindgen in wasmtime::component - Rust を利用するなど)、既に実装されたWASMコンポーネントを合成することで準拠することもできます。
この場合、WAC(WebAssembly Compositions )というツールが使えます。plugで依存するWASMコンポーネントを指定します。
wac plug --plug hoge.wasm --plug fuga.wasm -o output.wasm
さて、これだけでは高度な型(文字列や構造体・・etc)を異なる言語間で相互運用することはできませんね。 それを可能にする仕様がCanonical ABI(Canonical Application Binary Interface)です。
これは、WASMコンポーネントの高度な型含む値、関数とWASMコア仕様に準拠した値、関数に変換するための標準仕様です。
この仕様にWASMコンポーネントが準拠することにより、WASMモジュールのようにメモリレイアウトの違いによる相互運用性の困難さが解消されます。 CとRustでは文字列を表現する型のメモリレイアウトは全然違うと述べましたが、これらの境界を超えてどうバイナリで表現するのか?をCanonical ABIでは提案しています。
上記の例では2つのimportがありますので、これらから定義されるインターフェースに準拠する必要があります。 ホスト言語のコードにより実装することもできますが・・
と先ほど述べました。Canonical ABIを知った今なら、WITに準拠するホスト言語のコードはCanonical ABIに準拠している必要があることがわかりますね。
これは自分で実装すると大変です。ということで、 https://docs.rs/wasmtime/latest/wasmtime/component/macro.bindgen.html bindgen in wasmtime::component - Rust
のような、引数や戻り値をCanonical ABIに準拠するよう変換層を自動生成してくれるツールがあるわけです。
WASI(WebAssembly System Interface )?
WASIはWebAssemblyからOSの機能を実行するためのインターフェースの標準仕様となります。
元々は各WASMランタイムが独自でホストマシンのOS機能へのアクセス方法を実装していました。 しかし、これでは相互運用性に課題がありますね。ランタイム間でWASMモジュールを移植する際に、OS機能を利用する部分について書き直す必要があります。
また、元々Webで使われることを前提としていたため利用できる機能に制限がありました。徐々にWeb以外で利用されるWASMの可能性が広がっていたことから、より高度なOSの機能へのアクセスの需要が高まりました。
これらの背景から、インターフェースの標準仕様の需要が高まり、WASIが提案されました。
WASIに対応しているWASMランタイムであれば、同じインターフェースでFile IO、ネットワーク、Clock、Random、ScoketIOなどといったOS側の機能を利用することができます。
また、WASMを実行する際にどの機能へのアクセスを許可するかをコントロールすることができるため、セキュリティ面でも優れています。
WASI Preview 1とPreview 2
現在、WASIにはPreview 1と2(以下P1、P2と記載)の2つのバージョンが存在し、アーキテクチャが根本的に違います。
P1では、WASMモジュールを前提として策定されており、多くのランタイム(wasmtime、wasmer、WasmEdge・・etc)ですでにサポートされています。
ドキュメントを見てもわかる通り、P1ではPOSIXシステムコール風のインターフェースとなっています。
一方、P2ではWASMコンポーネントを前提として策定されており、インターフェース定義にWITが利用されています。
ということで、高レベルな型安全なインターフェース定義となっています。
例えば、P1ではエラーはエラーコード(=数値)で表していましたがP2ではResult型が利用することができます。
ただし、こちらの対応はまだ限定的です。そもそもWASMコンポーネントに完全に対応しているランタイムも限定的ですし。
wasmtimeでは、WASI P2が使えるようですね。 docs.wasmtime.dev
WAT(WebAssembly Text Format)?
これはWASMのランタイムやコンパイラの開発者以外はあまり気にすることはないかもしれません。
こちらのフォーマットはWASMバイナリをテキストで表現したものになります。WASMバイナリは人間が読むことができませんので。
用途としては色々考えられます。
- 私たちがWebAssemblyのコンパイラを作る際に、WATを中間表現として利用する
- WebAssemblyのランタイムを作る際にもWAT形式でテストケースを記述できると便利そう
- 凄まじい最適化
など。
WATはS式と呼ばれる構文で書かれています。S式の読み方は別のページを参照してください。
WATを読んでみる
下記はHelloWorldを出力するだけのWATです。こちらを一行ずつ読んでみることでWATへの理解を深めたいと思います。
(module
(import "wasi_snapshot_preview1" "fd_write"
(func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory 1)
(export "memory" (memory 0))
(data (i32.const 8) "Hello World\n")
(data (i32.const 0) "\08\00\00\00\0b\00\00\00")
(func $main (export "_start")
i32.const 1
i32.const 0
i32.const 1
i32.const 20
call $fd_write
drop
)
)
(1)
(module
ルートノード "module"はこの定義がWASMモジュールであることを示しています。
最小のWASMモジュールのWATは(module)となります。これをバイナリに変換すると、「0061 736d 0100 0000」となり、magic numberとversion fieldのみからなるWASMモジュールが定義されます。
(2)
(import "wasi_snapshot_preview1" "fd_write"
(func $fd_write (param i32 i32 i32 i32) (result i32)))
wasmtimeやwasmerなどのランタイムはWASI P1に対応しているので、このモジュールをimportすることができます。 ドキュメントはこちら
importしている関数fd_writeはファイルディスクリプタに書き込むものです。これを使えば標準出力に書き込みができますね。
fd_writeの定義はWASIのドキュメントを参照すると
fd_write(fd: fd, iovs: ciovec_array) -> Result<size, errno>
となっています。
一見、Result型などリッチな型定義がWASMモジュールにもかかわらずできているように見えますが、これは誤解です。 この定義は「WITX」という実験的なIDLに基づいて書かれています。WASI P2では後述した通りWITに移行されてますので、もう使われることはないのだろうと思います。
WITXでこの関数のインターフェース定義をみると、下記のようになっています。
(@interface func (export "fd_write")
(param $fd $fd)
;;; List of scatter/gather vectors from which to retrieve data.
(param $iovs $ciovec_array)
(result $error (expected $size (error $errno)))
)
Resut<size, errno>というのは(result $error (expected $size (error $errno)))を指します。
WITXでこのように書かれていると「戻り値としてはerrnoが返り(0成功、0以外エラー)、成功時には引数の最後に指定したポインタにsizeが格納される」というような定義になります。
ということで、 WATをみるとfd_writeの引数にはi32が4つありますが、それぞれ・・
1番目・・書き込み先のファイルディスクリプタを指定します。標準出力なら1。 2番目・・データのポインタ 3番目・・データのサイズ 4番目・・書き込まれたバイト数を格納するメモリ位置へのポインタ
という意味になります。
(3)
(memory 1) (export "memory" (memory 0))
(memory 1)で、WASMモジュールで1ページ分(64KB)のメモリを確保するという宣言です。 (export "memory" (memory 0))で、確保したメモリの0番目を外部に公開します。
WASMモジュールのメモリは外部からアクセスすることができません。 しかし、今回は標準出力に吐き出すためOS側がWASMモジュールのメモリにアクセスすることを許可しないといけないので、このようにエクスポートが必要となります。
(4)
(data (i32.const 8) "Hello World") (data (i32.const 0) "\08\00\00\00\0b\00\00\00")
fd_writeに渡すためのデータをメモリに書き込んでいます。
(data (i32.const 8) "Hello World\n")
は8番地(つまり9バイト目)からHello Worldという文字列をメモリに書き込んでいます。
(data (i32.const 0) "\08\00\00\00\0b\00\00\00")
こちらは、i32を2つ書き込んでいます。i32は4バイトなので「\08\00\00\00」と「\0c\00\00\00」の2つですね。 「\08\00\00\00」はこれはデータのポインタを示しています。先ほど、8番地からHelloWorldを書き込みましたね。
「\0b\00\00\00」はデータの長さを示しています。0bは10進数で11なので、8番地から11バイトとなります。Hello Worldはスペース含めて11文字ですので。
(5)
(func $main (export "_start")
i32.const 1
i32.const 0
i32.const 1
i32.const 20
call $fd_write
drop
)
いよいよ本体です。
(func $main (export "_start")
こちらは、$mainというこの関数を_startという名前でエクスポートする、ということを意味しています。
_startはデフォルトのエントリポイントになる関数であるとWASIでは定義されています。 これを公開することで、WASIに対応したランタイムはこのWASMモジュールを実行する時にこの関数を実行するようになります。
_start is the default export which is called when the user doesn't select a specific function to call. Commands may also export additional functions, (similar to "multi-call" executables), which may be explicitly selected by the user to run instead.
i32.const 1
i32.const 0
i32.const 1
i32.const 20
call $fd_write
callでfd_writeを呼び出します。その前にスタックにfd_writeの4つの値をプッシュしておきます。 fd_writeではこの4つの値をポップして利用します。 WASMはスタックマシンとして定義されるのでこのような定義となります。
各引数は先ほど説明した通りで、
最初のi32.const 1はファイルディスクリプタの1番、つまり標準入出力を指します。
2つ目のi32.const 0はデータのポインタです。メモリの最初のバイトには「\08\00\00\00」が書き込まれていたので、ポインタの値は8になります。メモリの8番地はちょうど「Hello world」の「H」が書き込まれていますね。
3つ目のi32.const 1はデータの長さを表します。メモリの次のバイトには「\0b\00\00\00」が書き込まれていたので、長さは11になります。
最後のi32.const 20は標準出力への書き込みが成功した時にその書き込みサイズを格納する領域を指します。この値は今回使わないので確保した1ページ分のメモリの中から使ってない適当な部分を指定しています。
drop
最後のdropは、「スタックの一番上のアイテムをドロップする」という意味です。
df_writeの戻り値(成功なら0、エラーならそれ以外)がスタックにつまれていますので、それを破棄しています。