Open9

wit-bindgen が生成する moonbit のコードとそこから生成されるWATを眺めて、wit-bindgen の実装を学ぶ

tanishikingtanishiking

https://github.com/tanishiking/moonbit-wasip2

(https://www.moonbitlang.com/blog/component-model とは少しやり方が変わっている)

moonbit + WASIp2 で hello を出力するやつ作った。これの moon build --target wasm で生成されるwasmコードと、wasm-tools の出力とを眺めて wasm-tools component の文脈で WASIp2 対応するために必要なことを調べていく。

Repositoryに書いているけど、やっていることまとめ

アプリケーションのWIT worldを書く。

$ cat wit/world.wit
package tanishiking:hello-moon-wasip2@0.0.1;

world run {
    import wasi:cli/stdout@0.2.2;
    export wasi:cli/run@0.2.2;
}

実行時のエンドポイントのため export wasi:cli/run@0.2.2; と 標準出力から記述するために import wasi:cli/stdout@0.2.2

依存する WIT ファイルを wasi.dev から取得

https://github.com/bytecodealliance/wasm-pkg-tools の fetch

$ wkg wit fetch

wit-bindgen で moonbit binding 生成

$ wit-bindgen moonbit wit --derive-show --derive-eq --out-dir .

gen/interface の cli/run/stub に実装

moon.pkg.json を編集して stdout package と streams package を使えるように

$ cat gen/interface/wasi/cli/run/moon.pkg.json
{
  "import": [
    {
      "path": "tanishiking/hello-moon-wasip2/interface/wasi/cli/stdout",
      "alias": "stdout"
    },
    {
      "path" : "tanishiking/hello-moon-wasip2/interface/wasi/io/streams",
      "alias" : "streams"
    }
  ]
}

実装

$ cat gen/interface/wasi/cli/run/stub.mbt
// Generated by `wit-bindgen` 0.35.0.
/// Run the program.
pub fn run() -> Result[Unit, Unit] {
    let stdout = @stdout.get_stdout()
    let hello : Bytes = b"hello"
    let _ = stdout.blocking_write_and_flush(hello)
    Ok(())
}

stdout って使った後に drop しなくていいのかな?

/// Drops a resource handle.
pub fn OutputStream::drop(self : OutputStream) -> Unit {
      let OutputStream(resource) = self
      wasmImportResourceDropOutputStream(resource)
}

run

$ moon build --target wasm
# Not sure why this is needed
$ wasm-tools component embed wit target/wasm/release/build/gen/gen.wasm -o target/wasm/release/build/gen/gen.wasm --encoding utf16

$ wasm-tools component new target/wasm/release/build/gen/gen.wasm -o target/wasm/release/build/gen/gen.wasm

$ wasmtime target/wasm/release/build/gen/gen.wasm
hello%
tanishikingtanishiking

$ moon build --target wasm で生成した wasm ファイルの中身を$ wasm-tools print target/wasm/release/build/gen/gen.wasmで見る。

全部 https://gist.github.com/tanishiking/d45473891a01ec241641ada2e2fb02cd

import

(type (;0;) (func (param i32 i32 i32 i32)))
(type (;1;) (func (result i32)))
  (import "wasi:io/streams@0.2.2" "[method]output-stream.blocking-write-and-flush" (func (;0;) (type 0)))
  (import "wasi:cli/stdout@0.2.2" "get-stdout" (func (;1;) (type 1)))

wasm component 文脈での import export で使う import name は このあたり で定義されていて

The plainname production captures several language-neutral syntactic hints that allow bindings generators to produce more idiomatic bindings in their target language. At the top-level, a plainname allows functions to be annotated as being a constructor, method or static function of a preceding resource. In each of these cases, the first label is the name of the resource and the second label is the logical field name of the function.

またWITのpackage nameはここにかかれているようにfoo:bar:baz/quux@x.y.z というような形式 (foo:bar:baz package の、 quux interface で、 バージョン x.y.z)

Package names are used to generate the names of imports and exports in the Component Model's representation of interfaces and worlds as described below

なので

(type (;1;) (func (result i32)))
(import "wasi:cli/stdout@0.2.2" "get-stdout" (func (;1;) (type 1)))

は、wasi:cli package の stdout interface 内で定義されている get-stdout 関数を import する

@since(version = 0.2.0)
interface stdout {
  @since(version = 0.2.0)
  use wasi:io/streams@0.2.2.{output-stream};

  @since(version = 0.2.0)
  get-stdout: func() -> output-stream;
}
;; https://github.com/WebAssembly/WASI/blob/5120d70557cc499b98ee21b19f6066c29f1400a4/wasip2/cli/stdio.wit#L10-L17

func() -> output-stream(result i32) に変換。なぜなら canonical ABI によると resource 型(own/borrow)を lower すると i32 https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#flattening


(type (;0;) (func (param i32 i32 i32 i32)))
(import "wasi:io/streams@0.2.2" "[method]output-stream.blocking-write-and-flush" (func (;0;) (type 0)))

こちらも同様に output-stream resource の blocking-write-and-flush 関数 を import する

@since(version = 0.2.0)
blocking-write-and-flush: func(
    contents: list<u8>
) -> result<_, stream-error>;

ん〜なんでこのinterface型に対して (type (;0;) (func (param i32 i32 i32 i32))) になるんだ? そもそも result 型はどこにいった

ああ、最初の3つのi32はそれぞれ、resource (i32), 可変長リスト(i32, i32), そして最後のi32はresultだ。

https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#flattening にあるように result 型が複数の core wasm type を保つ場合は linear memory を経由してデータをやり取りして、パラメータにi32のポインタを置くんだ

When there are too many flat values, in general, a single i32 pointer can be passed instead (pointing to a tuple in linear memory). When lowering into linear memory, this requires the Canonical ABI to call realloc (in lower below) to allocate space to put the tuple.

tanishikingtanishiking

生成された moonbit のコードを見ると、

pub fn get_stdout() -> @streams.OutputStream {

      let result : Int =  wasmImportGetStdout();
      return @streams.OutputStream::OutputStream(result)

}
// interface/wasi/io/streams/top.mbt
pub type OutputStream Int derive(Show, Eq)

//  interface/wasi/cli/stdout/ffi.mbt
// Generated by `wit-bindgen` 0.35.0. DO NOT EDIT!
fn wasmImportGetStdout() -> Int = "wasi:cli/stdout@0.2.2" "get-stdout"

https://docs.moonbitlang.com/ffi-and-wasm-host#declare-foreign-function
これがそのまま (import "wasi:cli/stdout@0.2.2" "get-stdout") になるのか


そして blocking_write_and_flush はこちら。

pub fn OutputStream::blocking_write_and_flush(self : OutputStream, contents : Bytes) -> Result[Unit, StreamError] {

      let OutputStream(handle) = self
      let return_area = @ffi.malloc(12)
      wasmImportMethodOutputStreamBlockingWriteAndFlush(handle, @ffi.bytes2ptr(contents), contents.length(), return_area);

      let lifted6 = match (@ffi.load8_u((return_area) + 0)) {
            0 => {

                  Result::Ok(())
            }
            1 => {

                  let lifted = match (@ffi.load8_u((return_area) + 4)) {
                        0 => {

                              StreamError::LastOperationFailed(@error.Error_::Error_(@ffi.load32((return_area) + 8)))
                        }
                        1 => {

                              StreamError::Closed
                        }
                        _ => panic()
                  }

                  Result::Err(lifted)
            }
            _ => panic()
      }
      ignore(contents)
      @ffi.free(return_area)
      return lifted6

}

return_area のために 12 bytes を malloc している。

Result 型は variant の特殊型なので

# result<_, stream-error> は
(variant
  (case "ok" )
  (case "error" stream-error)

stream-errorvarianterror は resource型

    @since(version = 0.2.0)
    variant stream-error {
        last-operation-failed(error),
        closed
    }

stream-errorについては、

  • discriminant_type は u8 (これは core wasm では i32 にlower)
  • payload は last-operation-failed(error) が最長なので i32
    よって stream-error[i32, i32] だし、alignmentは4

result<_, stream-error>

  • discriminant_type は u8(これは core wasm では i32 にlower)
  • payloadはstream-error ([i32, i32]) が最長
    よって result<_, stream-error>[i32, i32, i32] alignment は4なので padding も不要

なので result<_, stream-error> は12bytes必要。

  • @ffi.load8_u((return_area) + 0result のcase indexを読む。
    • 0ならokでデータ無しなのでOk(())を返す
    • 1ならstream-errorを返す
      • stream-errorもvariantなので最初の4バイトからcase indexを読み、0か1によってLastOperationFailedかClosedに対応するエラーを返す

これ、wit-bindgenがcanonicalABIでどういうデータ型になるかは計算してくれるんだろうか???

例えばcomponent-level functionの型をcore wasmに変換する部分は wit-parser に実装されていて

https://github.com/bytecodealliance/wasm-tools/blob/77122f121aa872c2495bf5782356a7e947014e4f/crates/wit-parser/src/abi.rs#L133-L197

tanishikingtanishiking
  • ところで Scala.js ではメモリに関するAPIを表に出さない(つまりIRにも出現しない)設計にしたい
  • そうなると wit-bindgen は単に Interface Type の対応する Scala のデータ構造に変換されるだけ
  • CanonicalABI相当のflattening処理はScala.jsのリンカーバックエンドが担うことになる
  • 多分型の変換だけで済む気がしているんだけど、moonbitとかrustのwit-bindgen、実装を見ていると思ったよりいろんなことをしているように見える? のでちょっと不安
  • ところで realloc って export されていることを期待するのかな?
tanishikingtanishiking

export編

componentからimportした関数をcore wasmで利用する方法はわかった。じゃあexportは?
core wasm で export されているものは以下

    (export "memory" (memory 0))
    (export "wasi:cli/run@0.2.2#run" (func 41))
    (export "cabi_realloc" (func 42))
;; ...
    (type (;1;) (func (result i32)))
    (func (;41;) (type 1) (result i32)

wasi:cli/runのrun は run: func() -> result なので (func (result i32)) になるのはOK

wasi:cli/run@0.2.2#run って名前は convention で決まっているのか?

最終的にはこの export された wasi:cli/run@0.2.2#run は以下のような感じに扱われるので名前は関係ないんだけど。

  (alias core export 3 "wasi:cli/run@0.2.2#run" (core func (;4;)))
  (func (;2;) (type 6) (canon lift (core func 4)))
  (instance (;3;) (instantiate 0
      (with "import-func-run" (func 2))
    )
  )

つまり wasm-tools が WIT をベースに component を作るタイミングでどうにかして、どの export が wasi:cli/run@0.2.2 の run 実装かを知っているべき

う〜ん custom section が怪しそう
https://github.com/bytecodealliance/wasm-tools/blob/5acc2dfcc9fd05ca46b53db129fbe3a3480cfeb4/crates/wit-component/src/metadata.rs#L19-L22

tanishikingtanishiking

現状の wasm-tools の実装はこのへん
https://github.com/bytecodealliance/wasm-tools/blob/549c283b2e14c4eba75daa35573b71cdcc110c5d/crates/wit-parser/src/lib.rs#L969-L994

だけど、この <interface>#<function> という名前はwasm-toolsの実装がそうなっているだけの "legacy standard" で
Add BuildTargets.md by lukewagner · Pull Request #378 · WebAssembly/component-modelで component model の文脈での core wasm module がどういう形になっているべきかを定めるドキュメントをspecに追加する予定らしい。

tanishikingtanishiking

ところでComponent Modelのresource使って、WasmGCのstructを外側にexportするときってGCどうなる?
今resource rep i32しかないけどrefも使えるようになったとして、resourceのdestructorでrefを破棄するようにすれば、外部からの参照が消えたときにrefも破棄されてそのうちGC対象になる?