手書きで理解するWebAssembly Component Model

目次

2024年1月25日にWASI (WebAssembly System Interface) 0.2 APIが安定版となり (日本語の記事)、4月9日にはRust foundationがWASI 0.2への対応を発表した (日本語の記事) ことで、WASIおよびWebAssembly Component ModelなどのWebAssembly (Wasm) を取り巻く状況が大きく変わろうとしています。

そこで、この記事では WebAssemblyテキスト形式 (コンポーネントに準拠した版) を手書きで書いて、できるだけ小さい実行可能なWASI 0.2のWasmバイナリを作って実行してみます。また、そのサンプルソースコードを通してWASI 0.2、WIT (WebAssembly Interface Type)、Component Modelを簡単に紹介したいと思います。

本記事で作るWebAssemblyバイナリ

今回、本記事で最終的に作りたいものは、WASIに準拠している小さなWebAssemblyバイナリです。そのため、起動したら戻り値を返すだけのメソッドを呼び出すWebAssemblyバイナリをつくります (C言語での int main() { return 0; }に相当するようなものです)。文字列出力はそれなりに大変なので、今回はその部分の説明が省ける単純な例を取り扱います。

WebAssembly core specificationでの最小のモジュール

コンポーネントのコードを書いていく前に、WebAssembly core specificationに基づいたモジュールのコードを書いてみましょう。WebAssembly core specificationに基づいた最小のモジュールは次のようになります。

 (module)

WebAssemblyテキスト形式で書かれた、何も機能を持たない空のモジュールを表現しているコードですが有効な表現であり、バイナリに変換することができます。WebAssemblyのユーティリティツールであるwasm-toolsを使い、wasm-tools parseでバイナリファイルに変換してみましょう。

❯ wasm-tools parse module-example.wat -o module-example.wasm

できたバイナリをwasm-tools dumpで中身を確認してみましょう。

❯ wasm-tools dump module-example.wasm
0x0 | 00 61 73 6d | version 1 (Module)
    | 01 00 00 00

このファイルは8バイトしかなく、最初の4バイトはWebAssemblyのマジックヘッダ (“\00asm”) で、次の4バイトがバージョン番号 (0x00000001) が書かれているだけです。

fileコマンドで見てみると次のように正しく判定されているのがわかります。

❯ file module-example.wasm
module-example.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

最小のコンポーネント

コンポーネントモデルでの最小のコンポーネントは次のようになります。

(component)

先ほどと同様にwasm-tools parseで変換できます。できたバイナリをwasm-tools dumpで確認してみましょう。

❯ wasm-tools dump component-example.wasm
0x0 | 00 61 73 6d | version 13 (Component)
    | 0d 00 01 00

最初の4バイトは先ほどと同じWebAssemblyのマジックヘッダですが、次の4バイトが違っているのがわかります。これはコンポーネントモデルのバイナリ定義で決まっており、最初の2バイトがバージョン、次の2バイトがレイヤーとなっています。

version   ::= 0x0d 0x00
layer   ::= 0x01 0x00

ちなみに、fileコマンドで見てみると次のような結果となります。コンポーネントモデルのことをよくわかっていないのかバージョン表示がおかしくなってしまいます。

❯ file component-example.wasm
component-example.wasm: WebAssembly (wasm) binary module version 0x1000d

関数をエクスポートしたモジュール

では一旦話をWebAssembly core specificationのモジュールに戻します。モジュール内に定数42を返すだけの単純な関数を定義し、それをエクスポートさせてみます。

(module
  (func (export "mod-main") (result i32)
    (i32.const 42))
)

これをバイナリに変換させてダンプしてみた結果がこちらです。

❯ wasm-tools dump module-example.wasm
 0x0 | 00 61 73 6d | version 1 (Module)
     | 01 00 00 00
 0x8 | 01 05       | type section
 0xa | 01          | 1 count
--- rec group 0 (implicit) ---
 0xb | 60 00 01 7f | [type 0] SubType { is_final: true, supertype_idx: None, composite_type: Func(FuncType { params: [], results: [I32] }) }
 0xf | 03 02       | func section
0x11 | 01          | 1 count
0x12 | 00          | [func 0] type 0
0x13 | 07 0c       | export section
0x15 | 01          | 1 count
0x16 | 08 6d 6f 64 | export Export { name: "mod-main", kind: Func, index: 0 }
     | 2d 6d 61 69
     | 6e 00 00
0x21 | 0a 06       | code section
0x23 | 01          | 1 count
============== func 0 ====================
0x24 | 04          | size of function
0x25 | 00          | 0 local blocks
0x26 | 41 01       | i32_const value:1
0x28 | 0b          | end

関数mod-mainを実行させてみましょう。WebAssemblyの実行環境のひとつWasmtimeを用いて、次のように実行すると正常に42という結果が表示されました。

❯ WASMTIME_NEW_CLI=0 wasmtime run module-example.wasm --invoke mod-main
warning: using `--invoke` with a function that returns values is experimental and may break in the future
42

モジュールを含んだコンポーネント

では前節で定義したモジュールを含めたコンポーネントを作ってみましょう。モジュールに対応したテキスト形式の仕様でmoduleの表記がcore moduleに変わっていますが、内容としては先ほどと同じです。

(component
  (core module
    (func (export "mod-main") (result i32)
      (i32.const 42))
  )
)

このファイルからバイナリを生成して、それをダンプしてみましょう。

❯ wasm-tools dump component-example.wasm
 0x0 | 00 61 73 6d | version 13 (Component)
     | 0d 00 01 00
 0x8 | 01 29       | [core module 0] inline size
   0xa | 00 61 73 6d | version 1 (Module)
       | 01 00 00 00
  0x12 | 01 05     | type section
  0x14 | 01        | 1 count
--- rec group 0 (implicit) ---
  0x15 | 60 00 01 7f | [type 0] SubType { is_final: true, supertype_idx: None, composite_type: Func(FuncType { params: [], results: [I32] }) }
  0x19 | 03 02     | func section
  0x1b | 01        | 1 count
  0x1c | 00        | [func 0] type 0
  0x1d | 07 0c     | export section
  0x1f | 01        | 1 count
  0x20 | 08 6d 6f 64 | export Export { name: "mod-main", kind: Func, index: 0 }
       | 2d 6d 61 69
       | 6e 00 00
  0x2b | 0a 06     | code section
  0x2d | 01        | 1 count
============== func 0 ====================
  0x2e | 04        | size of function
  0x2f | 00        | 0 local blocks
  0x30 | 41 01     | i32_const value:1
  0x32 | 0b        | end

コンポーネントのヘッダー8バイトに続いて、(WebAssembly Core Specificationの) モジュールのヘッダー8バイトがあるのがわかるでしょうか。コンポーネントのバイナリは前節のモジュールのバイナリをそのまま埋め込んだ形になっているのです。

コンポーネントモデルではこのようにWebAssembly Core Specificationで定義されたモジュールを複数内包することができます。またコンポーネント自体も内包できるので、依存関係のあるコンポーネントを組み合わせたコンポーネントを作るといったことも可能になります。

なお、Wasmtimeではこのバイナリを実行させることはできません。次のようなエラーが表示されます。

❯ WASMTIME_NEW_CLI=0 wasmtime run component-example.wasm
Error: failed to run main module `component-example.wasm`
Caused by:
   exported instance `wasi:cli/run@0.2.0` not present

このエラーはコマンドラインから起動するためのインタフェースwasi:cli/runにコンポーネントが準拠していないので実行できないということを示しています。WASI (WebAssembly System Interface) はファイルを操作する機能などの色々なインタフェースを提供しており、その中のひとつがwasi:cli/runです。

WITとインタフェースwasi:cli/run 

ではwasi:cli/runに準拠させてWasmtimeで実行できるようにしていきましょう。

そのためにまず、wasi:cli/runがどのようなインタフェースであるのか調べてみます。WASI 0.2の定義では、wasi:cli/runは次のように定義されています。

interface run {
 /// Run the program.
 run: func() -> result;
}

ここでは、「wasi:cli/runというインタフェースではrunというメソッドがあり、これは引数なしでresultを返す」ということを示しています。つまり、Rustで書くとfn run() -> std::result::Result<(), ()>のようなメソッドがあることを表しています。

ちなみに、これはWebAssembly Interface Type (WIT)と呼ばれるインタフェース定義言語(interface definition language; IDL)で書かれています。ProtocolBufferのprotoファイルのようなものと言えばわかる人にはわかりやすいと思います。

WITでは色々な型が定義されており、Rustのタプルやenum、さらにはOptionやResultのようなものも扱うことができます。一見resultに型パラメータが取れなさそうに見えますが次のような定義になっているので、 <> 自体を省略できます。

result ::= 'result' '<' ty ',' ty '>'
       | 'result' '<' '_' ',' ty '>'
       | 'result' '<' ty '>'
       | 'result'

コンポーネントの作成とコンポーネントのインスタンス定義

準拠すべきインタフェースはわかりましたので、それに準拠する次のようなコンポーネントを用意しましょう。

(component $Comp
  (import "main" (func $g (result (result))))
  (export "run" (func $g))
)

wasi:cli/runのrun関数と同じシグネチャになるようにしたmainという関数をインポートして、それをエクスポートしているだけです。(result (result)) という表記に戸惑うかもしれませんが、最初のresultは戻り値であるとを示すキーワードなので、戻り値としてresult型を取るということを表しているだけです。

次にこのコンポーネントのインスタンス定義を記述します。Core Specificationではモジュールを読み込むと、実行時に暗黙でモジュールがインスタンス化されて実行されますが、コンポーネントでは内部のサブモジュールやサブコンポーネントをどのようにインスタンス化するのかはコンポーネントに委ねられているので、インスタンス定義を記述する必要があります。

インスタンス定義では、今は何もせずにインスタンスをつくるようにしておきます。

(instance $c (instantiate $Comp
 ;; TODO: ここにmainと繋げるためのインスタンス化引数を書く
))

このコンポーネントのインスタンスをインタフェースwasi:cli/runとしてエクスポートさせます。

 (export "wasi:cli/run@0.2.0" (instance $c))

なぜこんな一見意味のなさそうな周りくどいことをしているのかと言うと、コンポーネントモデルの仕様ではコンポーネント自体に命令コードを含んだメソッドは書けないからです。また、wasi:cli/runインタフェースに準拠させるためのexportにモジュールインスタンスを指定することもできません。

以上の理由により、wasi:cli/runインタフェースに準拠したコンポーネントを作成して、そのコンポーネント経由でモジュールの関数を呼び出せるようにインポートの口としてmainを用意しています。

ちなみに、この時点でwasmtimeで実行させようとすると次のようなエラーになります。インポート宣言しているmainに繋ぐメソッドが指定されていないからです。

❯ wasm-tools parse minimum-component.wat -o minimum-component.wasm && wasmtime run minimum-component.wasm
Error: failed to parse WebAssembly module
Caused by:
   missing import named `main` (at offset 0x8c)

モジュールのインスタンス定義

ではモジュールの関数mod-mainをコンポーネントの関数mainに繋げていきましょう。

まず、コンポーネントと同様にモジュールもインスタンス定義を書かないとメソッドが使えないので次のように定義します。

 (core instance $m (instantiate $Mod))

これでmod-mainとmainを繋げることができそうですが、まだひとつ問題が残っています。それはコンポーネントがリッチな型を使えるのに対して、モジュールではプリミティブな整数型か浮動小数点数の型 (Core Specification 1.0 の場合) しか使えないことです。

この壁を乗り越えるためにCanonical ABIと呼ばれるものが定義されています。

Canonical ABIとlift

Canonical ABI (Application Binary Interface) はコンポーネントモデルにおけるコンポーネントでの値と関数を、WebAssembly Core Specificationにおけるモジュールでの値と関数に相互変換できるようにするインタフェースです。

Canonical ABIではliftとlowerという2つの操作を定義しています。

  • lift はcoreの関数をラップして、コンポーネントの関数で呼び出せるようにします
  • lower はコンポーネントの関数をラップして、coreの関数で呼び出せるようにします

今回はmod-mainをコンポーネントの関数として呼びたいのでmod-mainをliftするようにします。具体的には、モジュールの関数をcanon liftで包んだものを、繋げようとしているmainと同じシグネチャ (つまりwasi:cli/runのrunと同じインタフェースとなる) の関数として定義します。

 (func $main_lifted (result (result)) (canon lift (core func $m "mod-main")))

ちなみに、モジュールの関数のシグネチャ () -> i32 をliftしたものが、コンポーネントの関数のシグネチャ () -> result と一致することは、次のようにCanonical ABIのPython参照実装を使って確認することができます。

❯ curl -sLO https://raw.githubusercontent.com/WebAssembly/component-model/main/design/mvp/canonical-abi/definitions.py
❯ python
Python 3.12.3 (main, Apr 15 2024, 17:43:11) [Clang 17.0.6 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from definitions import *
>>> flatten_functype(FuncType([],[Result(None, None)]), 'lift')
CoreFuncType(params=[], results=['i32'])

最後にリフトされた関数をコンポーネントのインスタンス化引数に使います。コンポーネントのインスタンス定義内のTODOを消して、代わりに次の行を挿入してください。

 (with "main" (func $main_lifted))

これで実行できそうに思えますが、またしても実行時にエラーになってしまいます。

❯ wasm-tools parse minimum-component.wat -o minimum-component.wasm && wasmtime run minimum-component.wasm
Error: failed to run main module `minimum-component.wasm`
Caused by:
   0: failed to invoke `run` function
   1: invalid expected discriminant

これはi32からresultへの変換で0のときはok、1のときはerror、それ以外は実行時エラーになるからです。それを先ほどと同様にPythonの参照実装で確認してみます。

>>> lift_flat(CallContext(CanonicalOptions(), ComponentInstance()), CoreValueIter([0]), Result(None, None))
{'ok': None}

>>> lift_flat(CallContext(CanonicalOptions(), ComponentInstance()), CoreValueIter([1]), Result(None, None))
{'error': None}

>>> lift_flat(CallContext(CanonicalOptions(), ComponentInstance()), CoreValueIter([42]), Result(None, None))
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/private/tmp/definitions.py", line 945, in lift_flat
   case Variant(cases) : return lift_flat_variant(cx, vi, cases)
                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 File "/private/tmp/definitions.py", line 983, in lift_flat_variant
   trap_if(case_index >= len(cases))
 File "/private/tmp/definitions.py", line 22, in trap_if
   raise Trap()
definitions.Trap

というわけで、(i32.const 42) の42の部分を0または1に変えることで正常に実行されるようになります。実行させても当然何も表示されませんが、終了時ステータスを確認すると実行結果が変わっていることが確認できます。

❯ # (i32.const 0) のとき
❯ wasmtime run minimum-component.wasm; echo $?
0

❯ # (i32.const 1) のとき
❯ wasmtime run minimum-component.wasm; echo $?
1

最後に、できあがったバイナリのサイズを見てみましょう。

❯ ls -l minimum-component.wasm
-rw-r--r--  1 mac  staff  285  5 27 22:31 minimum-component.wasm

ちなみにこのバイナリにはカスタムセクションが含まれるのでstripすることでもっと小さくできます。最終的に185バイトのWebAssemblyバイナリになりました。

❯ wasm-tools strip minimum-component.wasm -o minimum-component-stripped.wasm
❯ ls -l minimum-component-stripped.wasm
-rw-r--r--  1 mac  staff  185  5 27 22:37 minimum-component-stripped.wasm

まとめ

最終的に次のようなコードになりました。

(component
 (core module $Mod
   (func (export "mod-main") (result i32)
     (i32.const 0))
 )

 (core instance $m (instantiate $Mod))
 (func $main_lifted (result (result)) (canon lift (core func $m "mod-main")))
 (component $Comp
   (import "main" (func $g (result (result))))
   (export "run" (func $g))
 )

 (instance $c (instantiate $Comp
     (with "main" (func $main_lifted))))

 (export "wasi:cli/run@0.2.0" (instance $c))
)

このコードのコンポーネントやモジュールの関係を表したのが次の図になります。

図の上部にはコンポーネントが、下部にはモジュールがあり、その間には概念的な境界があると思ってください (それを乗り越えるためにはCanonical ABIのliftもしくはlowerの操作が必要)。それぞれの矩形はモジュールやコンポーネントおよびそれらのインスタンスで、最上部にはコード上で付けた名前を、それ以降にはインポートやエクスポートした関数の名前を書いています。

この図は、コンポーネント$Compとモジュール$Modからそれぞれ$cと$mをとしてインスタンス化させ、モジュールインスタンス$mのエクスポート関数mod-mainとコンポーネントインスタンス$cのインポート関数mainをliftして繋いでいることを示しています。

おわりに

WebAssemblyテキスト形式のコンポーネントモデルを手書きで書いて、WASI 0.2として実行できる185バイトのWebAssemblyバイナリを作りました。

最初は自作のWasmのインタプリタにWASIを実装してみよう、くらいに軽い気持ちでWASI 0.2について調べていたのですが、0.1 (preview 1)と比べて仕様が大きく変わっていたことに驚きました。仕様を読んでいるだけでは理解がなかなか進まなかったので、cargo componentで作られるサンプルのバイナリをwasm-tools printでテキスト形式に変換して、不要なコードを削っていきながら動作を確認してようやく理解が深まりました (とは言え理解が足りていない部分もあるので、間違いなどがありましたら指摘していただけるとありがたいです)。

今回紹介した内容は、WebAssemblyのコンパイラやインタプリタなどを作っているなら必要な知識ですが、WASIをサポートしている言語を使うのであれば細かい内容はあまり気にしなくてよいと思います。例えばRustではネイティブサポートはまだですが、cargo componentを使えばこれらのことを意識せずにコンポーネントモデルのバイナリを作成することができます。

コンポーネントモデルは、WebAssemblyの言語中立性を活かして、いろいろな言語から作られたWebAssemblyバイナリをお互いに取り使えるようにするものなので、将来は言語に依存しない形での Write once, run anywhere な世界が実現しているかもしれません。

 

「バックエンド」の関連記事

ブログ一覧へ