最もプリミティブなやり方(WAT)で入門するWASM Component Model
WebAssembly Advent Calendar 2024の18日目の記事です。
@udzura です。趣味でPure RubyのWASM Runtimeを作ったり、mrubyをベースにRubyスクリプトをWASMバイナリに固めるくんのPoCを作ったりしています(なおいわゆるruby.wasmにはほとんど関わっていません)。
今日は、Component Modelについて何もわからなかったので、素人なりに手を動かしてわかったことのメモをしていこうと思います。Component Modelの肌感覚を身につける上で、本記事が少しでも助けになれば幸甚です。
Component ModelでWASMバイナリを「リンク」する
Component Modelの学習コンテンツの一つとして、「Building a Calculator of Wasm Components」というチュートリアルがあるのをご存知でしょうか?
このチュートリアルでは、Rustとwit-bindgenをベースに、
docs:adder/add@0.1.0インタフェースを実装したバイナリdocs:adder/add@0.1.0インタフェースに依存する、docs:calculator/calculate@0.1.0インタフェースを実装したバイナリ
をそれぞれ組み合わせて、 wasi:cli/run@0.2.0 を実装したバイナリにリンクして実行させるということができます。リンクには wac というコマンドを使っています。
が、wit-bindgenをベースににしているため、個人的にはどうにも隠蔽されまくっている感がすごくて...、最もフィジカルで、最もプリミティブで、そして最もフェティッシュなやり方でComponent Modelを行かせていただきたいと思いました。
そこで、wat表記のプログラムを組み合わせる形でComponent ModelベースのWASMの「リンク」を実行する手順を作ってみました。
本手順の途中で CanonicalABI のlistとlowerについても少し触れる予定です。
インストールするコマンド
必要なコマンドは wasm-tools 、 wac 、そしてランタイムとしては wasmtime を使うのでそれぞれ入れておきましょう。
$ cargo install --locked wasm-tools $ cargo install wac $ curl https://wasmtime.dev/install.sh -sSf | bash # 参考バージョン $ wac --version wac-cli 0.6.1 $ wasm-tools --version wasm-tools 1.218.0 $ wasmtime --version wasmtime 25.0.1 (b4cb894c9 2024-09-24)
最初のバイナリ
以下のような judge.wat を作成します。
(component
(core module (;0;)
(export "hi_orig" (func $hiCore))
(func $hi_core (param i32) (result i32)
(if (result i32) (i32.gt_u (i32.const 10) (local.get 0))
(then
(i32.const 0))
(else
(i32.const 1))
)
)
)
(core instance (;0;) (instantiate 0))
(alias core export 0 "hi_orig" (core func $hiCoreAlias))
(type (;0;) (func (param "target" u32) (result u32)))
(func $doHi (type 0) (canon lift (core func $hiCoreAlias)))
(component $C
(import "do-hi" (func $f (param "target" u32) (result u32)))
(export "hi" (func $f))
)
(instance $c (instantiate $C
(with "do-hi" (func $doHi))))
(export (;0;) "advent:example/judge@0.0.1" (instance 0))
)
このwatファイルを wasm-tools parse でコンパイルします。
$ wasm-tools parse -o judge.wasm judge.wat
すると、以下のようなwitで表現されるインタフェースを公開したWASMバイナリになります。
$ wasm-tools component wit judge.wasm package root:component; world root { export advent:example/judge@0.0.1; } package advent:example@0.0.1 { interface judge { hi: func(target: u32) -> u32; } }
このwatのコードのそれぞれがどういう意味なのか注釈をつけておきます。
(component
(core module (;0;) ;; core moduleの定義
(export "hi_orig" (func $hiCore)) ;; $hiCore 関数が"hi_orig"としてexportされている
(func $hi_core (param i32) (result i32) ;; 引数が10以下なら0、そうでなければ1を返す関数
(if (result i32) (i32.gt_u (i32.const 10) (local.get 0))
(then
(i32.const 0))
(else
(i32.const 1))
)
)
)
(core instance (;0;) (instantiate 0)) ;; core moduleのインスタンス0番目
;; 上のcore moduleのインスタンスの、"hi_orig"というexportにaliasを貼っている
;; $hi_core_alias の宣言がないと下でcanon liftの対象に使えない
(alias core export 0 "hi_orig" (core func $hiCoreAlias))
(type (;0;) (func (param "target" u32) (result u32)))
;; component関数$doHiを、core func $hiCoreAliasのcanon liftとして定義
(func $doHi (type 0) (canon lift (core func $hiCoreAlias)))
;; component $C は"do-hi"に依存し、"hi"をexportする
;; "hi" は変数 $f を通じて上のimport関数をそのままexport
(component $C
(import "do-hi" (func $f (param "target" u32) (result u32)))
(export "hi" (func $f))
)
;; $C を"do-hi" = (func $doHi)として注入してインスタンス化する
(instance $c (instantiate $C
(with "do-hi" (func $doHi))))
;; 上のインスタンスのexport関数を "advent:example/judge@0.0.1" インタフェース関数 "hi" としてexport
(export (;0;) "advent:example/judge@0.0.1" (instance 0))
)
このバイナリにおいては、Core WASMの関数 $hiCore を最終的に advent:example/judge@0.0.1#hi というComponent Modelの関数としてexportしています。すなわち、 Core WASMの関数をComponent Modelの関数として使えるようにしています 。これが lift です。
一旦このComponentでやっていることを模式図にしました。僕がこう理解してるっすという図なことを承知おきください。
二番目のバイナリ
続いて以下の front.wat をコンパイルしてみましょう。
(component
(type $T
(instance (;0;)
(type (;0;) (func (param "target" u32) (result u32)))
(export (;0;) "hi" (func (type 0)))
)
)
(import "advent:example/judge@0.0.1" (instance (;0;) (type $T)))
(core module (;0;)
(type (;0;) (func (param i32) (result i32)))
(import "advent:example/judge@0.0.1" "hi" (func $_hi (;0;) (type 0)))
(func $_main (;1;) (export "main0") (result i32)
i32.const 1
call $_hi
)
)
(alias export 0 "hi" (func $toLower))
(core func $_hi (;0;) (canon lower (func $toLower)))
(core instance $m0 (;0;)
(export "hi" (func $_hi))
)
(core instance $m (instantiate 0
(with "advent:example/judge@0.0.1" (instance $m0))
))
(func $lifted (result (result)) (canon lift (core func $m "main0")))
(component $C
(import "main" (func $g (result (result))))
(export "run" (func $g))
)
(instance $c (instantiate $C
(with "main" (func $lifted))))
(export "wasi:cli/run@0.2.0" (instance $c))
)
$ wasm-tools parse -o front.wasm front.wat
こういうwitが出力されます。
wasm-tools component wit front.wasm package root:component; world root { import advent:example/judge@0.0.1; export wasi:cli/run@0.2.0; } package advent:example@0.0.1 { interface judge { hi: func(target: u32) -> u32; } } package wasi:cli@0.2.0 { interface run { run: func() -> result; } }
こっちはもっとややこしいのですが、自分なりの解釈で注釈を付けます。
(component
;; まず、type $T を定義
;; この $T は "hi" をexportしたインタフェースを表す
(type $T
(instance (;0;)
(type (;0;) (func (param "target" u32) (result u32)))
(export (;0;) "hi" (func (type 0)))
)
)
;; そのtype $Tをインスタンス化する(instance の引数はtypeとcomponentが取れる)
;; "advent:example/judge@0.0.1"として import すべきインタフェースにしている
(import "advent:example/judge@0.0.1" (instance $t (type $T)))
;; Core Moduleを定義している
;; ここで、"advent:example/judge@0.0.1"の"hi"をimportしていることに注目する
;; Core Moduleでimport宣言している外部の関数は、Coreの関数としてはindex 0になる。
;; ここではその外部関数に $_hi と名付けている
(core module (;0;)
(type (;0;) (func (param i32) (result i32)))
(import "advent:example/judge@0.0.1" "hi" (func $_hi (;0;) (type 0)))
;; $_main は index 1 の関数である。 $_hi をi32.constの引数で呼ぶだけ
(func $_main (;1;) (export "main0") (result i32)
i32.const 1
call $_hi
)
)
;; 上のtype $T インスタンス $t の "hi" に $toLower という名前をつけている
(alias export $t "hi" (func $toLower))
;; コア関数の$_hiはcomponentの$toLower関数のlowerで実現する、と宣言
(core func $_hi (;0;) (canon lower (func $toLower)))
;; Core moduleのインスタンス$m0を宣言し、 $_hi関数を"hi"としてexport
(core instance $m0 (;0;)
(export "hi" (func $_hi))
)
;; もう一つCore moduleのインスタンス、これは"advent:example/judge@0.0.1"モジュールを$m0インスタンスに結びつけている
;; $m0インスタンスは "hi" という関数をexportしているのでここでimportできる
(core instance $m1 (instantiate 0
(with "advent:example/judge@0.0.1" (instance $m0))
))
;; Core $m インスタンスの "main0" をComponent funcの$liftedのliftとしている
(func $lifted (result (result)) (canon lift (core func $m1 "main0")))
;; 二つ目のコンポネントの定義
(component $C
(import "main" (func $g (result (result))))
(export "run" (func $g))
)
;; 上記を実体化し、"main"を$liftedに繋ぎこむ
(instance $c (instantiate $C
(with "main" (func $lifted))))
;; 最後に $c の"run"を"wasi:cli/run@0.2.0"インタフェースの実装としてexportしておしまい
(export "wasi:cli/run@0.2.0" (instance $c))
)
内部的には Component のインスタンスが2つあり、それぞれがimportとexportをしており、それらをCore WASMのインスタンスがliftとlowerで繋いでいます、ということだと理解できました。自信はありませんが。また、Core WASMのインスタンスを外部モジュール扱いにして、それと繋げてCore WASMのインスタンスを作るということもしているようです。これも自信はありませんが...。
これもインスタンスの繋がりを模式図にします。
ここでlowerという操作が出てきます。すなわち、Componentのインタフェースに定義されている (func (param "target" u32) (result u32)) というシグネチャの関数を、Core互換の (func (param i32) (result i32)) として使っているのがポイントかなと思います。
また、この図におけるtype $Tのインスタンスの部分には、別の実体のあるComponentのインスタンスが挿入されることが想定されています。その別のComponentは逆にtype $Tをexportしている必要があり、つまり (func (param "target" u32) (result u32)) というシグネチャの関数 hi を実装していないといけないはずです。
Component自体には関数のコードが含まれることはないので、わかりづらいのですが、基本的にインタフェースとして定義された外部コンポーネントの関数が、Core Moduleでインポートすべき関数に対応するときに出てくる操作と理解できました。
やっとリンクする
最後に、上記のようなwitを持っている2つのバイナリをリンクします。
$ wac plug front.wasm --plug judge.wasm -o composed.wasm
composed.wasm もComponentなWASMバイナリですが、 wasi:cli/run@0.2.0 をexportしています。
$ wasm-tools component wit composed.wasm package root:component; world root { export wasi:cli/run@0.2.0; } package wasi:cli@0.2.0 { interface run { run: func() -> result; } }
したがって、これは wasmtime でそのまま実行できるWASMバイナリです。ここでは終了コードしかわかりませんが...。
$ wasmtime composed.wasm ; echo $? 0
front.watの i32.const を 1 から、10以上の何かしらの値( 42 とか)に変えてビルドし直すと、ちゃんと挙動が変わります。お試しください。
ところでこの composed.wasm を wasm-tools print すると、以下のようにcomponentの中にcomponentが2つある構造をしています。componentは入れ子にできるのですね。結果的には、一つのcomponentの中に core module を2つ以上含むことができるということでもあります。
(component (component (;0;) (core module (;0;) ;;..... ) ) (instance (;0;) (instantiate 0)) (alias export 0 "advent:example/judge@0.0.1" (instance (;1;))) (component (;1;) (core module (;0;) ;;..... ) ;; .... ) )
実は、こういう融通無碍な構造を取れる点と、内部でcomponentやcore moduleをどうインスタンス化するか指定できる点が、Component Modelの重要なコンセプトなのではと思いました。
今回作図した模式図のオリジナルを以下に公開しました。
プリミティブかと思いきやただひたすらフィジカルだった...。
Core WASMのシンプルさに比べると複雑さを感じますが、内部的な構造を理解すれば一貫性はある設計なのかな? とも思いました。
この辺の挙動に対応する仕様の記述・議論のIssueなどのポインタもぜひ教えてください。