手書きWebAssembly Component ModelでHello world! #ヌーラボ真夏のブログリレー2024

ヌーラボの松本です。これはヌーラバー真夏のブログリレー2024の2日目の記事です。

以前の記事でWebAssemblyテキスト形式でWebAssemblyコンポーネントを手書きして、 実行できるWebAssemblyバイナリを作成しました。

本記事では、そこからさらにWASIで定義されている出入力APIを使って標準出力にHello world!を表示させる Wasmバイナリを作っていきます。

標準出力に用いるインタフェース・メソッドを調べる

まずは標準出力を使うためのインタフェースを見ていきましょう。WASI 0.2ではcli/stdioというインタフェースが用意されていますので、まずはそのWITを見てみます (stdio.witに定義されているインタフェースは他にもstdinとstderrがありますがここでは省略して今回必要な部分のみ示しています)。

interface stdout {
  use wasi:io/streams@0.2.0.{output-stream};

  get-stdout: func() -> output-stream;
}

インタフェースstdoutがあり、その中に関数get-stdoutがあります。 これは標準出力を操作するリソースoutput-streamを返す関数です。

では、リソースoutput-streamが定義されているio/streamsを見てみます。このコードも今回の説明に必要な部分以外は省略しています。

interface streams {
    resource output-stream {
        write: func(
            contents: list<u8>
        ) -> result<_, stream-error>;
    }
}

インタフェースstreams内にリソースoutput-streamがあり、その中にメソッドwriteがあります。 resultは前回紹介したようにWITの組み込みデータ型です。 型stream-errorは、output-streamと同じインタフェースstreams内に定義されています。

use error.{error};

variant stream-error {
    last-operation-failed(error),
    closed
}

variant型はWITの組み込みデータ型で、Rustのenumのようなものです。

さらに、last-operation-failedで使われている型errorは use error.{error}; となっているのでio/errorの定義を見てみましょう。

interface error {
    resource error {
        to-debug-string: func() -> string;
    }
}

上記をまとめると、次のような手順で呼び出すことができます

  1. 関数get-stdiotを呼んで、リソースoutput-stream型をとる標準出力が返される

  2. 返された標準出力の関数writeを使って list のバイト列に含まれている “Hello, world!\n” を表示させる

さて、呼び出しかたがわかったのでコードを書いていきたいところですが、 引数の受け渡しの部分が前回より複雑になるのでCanonical ABIについて前回よりもう少し詳しく説明します。

Canonical ABIとlower

Canonical ABIは前回の記事で説明した通り、liftとlowerという2つの操作を定義しています。

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

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

どのようにラップしているのかをもう少し具体的に見ていくと次のようになります。

内部的にはlift_values, lower_values という操作が定義されています (最近async関連機能が追加されて、例えばlift_valuesはlift_sync_valuesとlift_async_valuesになりましたがここではまとめてlift_valuesとlower_valuesとします)

  • lift_values はcore関数のバイト列からコンポーネントの型の値に変換する

  • lower_values はコンポーネントの型の値からcore関数のバイト列に変換する

この2つの操作を使って、lowerのときにはラップしたい関数funcを呼び出す前にlift_valuesによって、 core関数から与えらえるバイト列flat_artsからコンポーネントの型に基づいた値argsに変換し、このargsを使ってfuncを呼び出します。 戻り値resultが帰されるので、これをlower_valuesで再びバイト列に変換します。

こうすることで、lower化されたモジュールの関数はcoreの関数として使うことができます。 liftはその逆の操作を行うことでlift化されたcoreの関数はモジュールの関数として使うことができます。

前回の記事ではcore関数のmod-mainをliftしたものをコンポーネント側で使っていました。 これを図にすると次のようになります。

前回の記事で説明した、戻り値は0か1の値を返すことができて、それ以外の値を返そうとするとエラーになると いうことをこの図では示しています。

続いて、今回使う関数get-stdoutの呼び出しを図にすると次のようになります。

resource型はコンポーネントモデルの基礎型の中にあるハンドル型であるownもしくはborrowとして扱われますが、 これはどちらもlowerしたときi32となります。

では最後にリソースoutput-stream型の関数writeの呼び出しを図にみましょう。これは次のようになります。

リソース内の関数なので暗黙の引数として最初にリソースoutput-stream型の値があり、 これはget-stdoutのときに説明した通りi32になります。get-stdoutで返されるi32をそのまま使えばOKです。

次の引数であるcontentsのlistは二つのi32となります。それぞれ出力したい配列のメモリ上での先頭の位置と長さを指定することになります。

最後に付いてくるi32は戻り値となる値を格納する場所のメモリ上での先頭の位置を指定します。 なぜこんな回りくどいことをしているかと言うと、現在のWebAssemblyの仕様では戻り値を (i32, i64などのプリミティブ型のみで) ひとつまでしか返せないからです。

writeの戻り値の型 result<_, stream-error> は、

  • ひとつめのi32はresult<_, stream-error>を表現する。0はok、1はerrorとなる

  • ひとつめのi32が1 (error)のとき、ふたつめのi32はstream-error型を表現する。0はlast-operation-failed、1はclosedとなる

  • ふたつめのi32が0 (last-operation-failed)のとき、みっつめのi32はresouce型のerrorを表現する。これはハンドル型となるのでi32となる

のようになり、lowerされると3つのi32が必要になります。これはWebAssemblyの仕様では戻り値として返すことはできません。そこで戻り値をなくして、代わりに上で述べたように引数が一つ追加させて戻り値を格納するメモリの位置を指定させるようになるのです。

Hello world!を表示させる

上記のようなCanonical ABIの処理を考慮したとき、Hello world!を表示させる処理は次のようなコードになります。

(core module $Mod
  (func $get_stdout (import "output-stream" "lower-get-stdout") (result i32))
  (func $write (import "output-stream" "lower-write") (param i32 i32 i32 i32))

  (func (export "mod-main") (result i32)
    (call $get_stdout)
    (i32.const 0)  ;; offset
    (i32.const 14) ;; length
    (i32.const 16) ;; return value
    (call $write)
    (i32.const 0))
)

関数mod-main内の(call $get_stdout)が実行されたら、スタックの一番上にoutput-streamがある状態になります。 その状態でメモリにあらかじめ配置しておいた”Hello, world!\n”の先頭位置と長さを指定し、 最後に戻り値が返されるメモリの先頭位置を指定してからwriteを呼び出します。

メモリはどのように初期化しているかというと、次のようにメモリだけのcoreインスタンスを別途作って、それをwriteで使うように設定しています。

(core module $MemMod
  (memory (export "memory") 1)
  (data (i32.const 0) "Hello, world!\n")
)
(core instance $mem_mod (instantiate $MemMod))
(alias core export $mem_mod "memory" (core memory $mem))

(core func $get_stdout (canon lower (func $wasi_cli_stdout "get-stdout")))
(core func $output_stream_write (canon lower (func $wasi_io_stream "[method]output-stream.write") (memory $mem)))

ちなみに、既存のcoreモジュール$Modに入れることもできそうに見えますが、 メソッドwriteをcanon lowerするときにmemoryが必要となるので、ここで$Modを指定しようすると循環参照にとなり、コンポーネントモデルの記述では循環参照が禁止されているので、バイナリ読み込み時にバリデーションエラーになってしまいます。

WASIインタフェース定義の追加

ここからさらに必要なコードはWASIインタフェース定義と繋ぎ込みの部分です。WASIインタフェースについてはWITの定義からインポートの定義から一意に決まるので、説明は省略します。

(import "wasi:io/error@0.2.0" (instance $wasi_io_error 
  (export "error" (type (sub resource)))
))
(alias export $wasi_io_error "error" (type $error_type))

(import "wasi:io/streams@0.2.0" (instance $wasi_io_stream
  (export $os "output-stream" (type (sub resource)))

  (export $err "error" (type (eq $error_type)))
  (type $errval (variant (case "last-operation-failed" (own $err)) (case "closed")))
  (export $error "stream-error" (type (eq $errval)))

  (export "[method]output-stream.write"
    (func
      (param "self" (borrow $os))
      (param "contents" (list u8))
      (result (result (error $error)))))
))
(alias export $wasi_io_stream "output-stream" (type $output_stream_type))

(import "wasi:cli/stdout@0.2.0" (instance $wasi_cli_stdout 
  (export $os "output-stream" (type (eq $output_stream_type)))
  (export "get-stdout" (func (result (own $os))))
))

完成したコードはGitHubにありますのでそちらを参照してください。このコードのコンポーネントやモジュールの関係を表したのが次の図になります。

バイナリの作成と実行

WebAssemblyのユーティリティツールであるwasm-toolsでバイナリファイルに変換し、これをWebAssemblyの実行環境のひとつWasmtimeで実行すると正常にHello, world!という結果が表示されました。

❯ wasm-tools parse hello.wat -o hello.wasm
❯ wasmtime hello.wasm
Hello, world!

最後に、できあがったWasmバイナリと、それをストリップしたバイナリのサイズを見てみましょう。

❯ ls -l *.wasm
-rw-r--r--  1 mac  staff   835  7 11 17:08 hello-stripped.wasm
-rw-r--r--  1 mac  staff  1107  7 11 17:07 hello.wasm

835バイトのWebAssemblyバイナリになりました。参考として、Rustのcargo component newで生成される雛形もHello, world!を表示するので、それで生成できるWasmバイナリのサイズも出してみました。Rustで生成されたバイナリをストリップしてものが94Kバイトなので、手書きすることで100分の1以下まで小さくなりました。

❯ cargo component new my-component
❯ cd my-component
❯ cargo component build --release
❯ cd target/wasm32-wasi/release/
❯ wasm-tools strip my-component.wasm -o my-component-stripped.wasm
❯ ls -l *.wasm
-rw-r--r--  1 mac  wheel    94297  5 27 22:45 my-component-stripped.wasm
-rw-------@ 1 mac  wheel  1721239  5 27 22:43 my-component.wasm

WebAssembly/component-modelへの貢献

非常に小さい貢献ではありますが、本記事を書くにあたって、 Canonical ABIの参照実装の不具合らしき箇所についてissueを上げたところ、Component Modelで一番偉い人であるLuke Wagner氏がすぐに対応してくれました (もっとも2日後にAsync関連のコードがコミットされてその辺りのコードが消えてしまいましたが)。

おわりに

WebAssemblyテキスト形式のコンポーネントモデルを手書きで書いて、Hello, world!を表示できるWebAssemblyバイナリを作りました。

今回コンポーネントモデルの仕様を読んで、 WebAssemblyの言語中立なバイナリフォーマットとしての役割が WebAssemblyはコンポーネントモデルとCanonical ABIによって、さらに大きくなりそうだと感じました。Async機能が追加されたことで、 RustやSwiftなど非同期処理を扱える言語での多くのコードがWebAssemblyで使えるようになることが期待できそうです。

コンポーネントモデルはメモリの共有などは基本的に行いません (Shared nothing)。 メソッドの呼び出し時には値をコピーしたものを渡すことで必要な情報以外は共有されておらず、 メモリを共有したいときはコンポーネント側で明示的に指定する必要があります。 これによって、パフォーマンス優先でメモリなどを全部共有する、出自不明のモジュールは情報漏洩のリスクがあるので必要な情報以外は共有しない、ということをコンポーネントで側で制御できるため、 サプライチェーン攻撃を仕掛けられたとしてもメモリを隔離しておいたので被害を防げた…みたいなことが期待できるかもしれません。

コンポーネントモデルのWasmバイナリのためのパッケージレジストリができたりしていますので、 仕様の策定が進んでいくにつれ、ツールなども含めたエコシステムがより充実していくことが期待できそうです。

 

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる