knqyf263's blog

自分のためのメモとして残しておくためのブログ。

GoのWasmランタイム上でZigで作ったWasmモジュールを動かす

Zigのコミッタの方から有益なフィードバックを頂いたり、Wasmランタイムのメンテナからコメントしてもらったり、Twitterでプロ開発者に助けてもらったり、と良い話が多かったのでそのへんの話も含めつつメモとして残しておきます。

最初に断っておきますがWasmにもZigにも特に詳しくないです。

概要

自分の開発しているOSSWebAssemblyによるプラグイン機能に対応したのですが*1、Wasmは仕様が小さく関数の引数や戻り値もi32/i64/f32/f64だけで頑張るみたいな世界なので直接ユーザに生のWasm用コードを書いてもらうのは利便性の面で厳しいです。*2

そうなると各言語ごとにSDKを用意する必要があるわけですが、自分の開発しているOSSがGoで書かれていることもあり最初はTinyGoに対応しました。GoだとWASI対応していないのでGo+WASIをやりたい場合は必然的にTinyGoになるかと思われます。TinyGoだとWasmもシュッとビルドできて便利なのですが、Goの標準パッケージで対応されていないものがそこそこあって(v0.25.0時点)何も考えずに普通にGoを書くときの気持ちでいるとビルドできないことが多いです。

tinygo.org

上のドキュメントを見てもらえれば分かりますが encoding/json などが使えなかったりします。あと自分が開発していた時はTinyGo v0.23.0だったためGoのジェネリクスに対応しておらず、適当にパッケージをimportすると死ぬということが多発しました。さらにエラーだとジェネリクスが原因というのがぱっと分からずしばらくハマったりしました。v0.24.0でジェネリクス対応されたようなので今はもう少し楽になっているとは思いますし開発も盛んなのでどんどん便利になっていくと思いますが、自分の用途ではそこまでリッチな機能は不要だったのでWasm用に何か仕様の小さな言語はないかなと探していました。

そこで最近話題になっていたZigを試してみたという記事です。

ziglang.org

Zigがどういう言語かというのは最近日本語でも記事が多く出ているので割愛しますが、1日頑張って読めば終えられるぐらいチュートリアルが短いです。

ziglearn.org

Zig + Wasmの記事はいくつか見つけたのですが大体Hello worldに留まっていました。ちょうど自分がZigを書き始めようとしたタイミングでCloudflareのブログも公開されたので「これは!」と思って中を見たらHello worldだったのでく〜〜〜となってました。

blog.cloudflare.com

挨拶されすぎて世界も困惑してますよ!!!ということでもう少し実践的な内容を自分でやってみたのでメモです。

実装

GoのWasmランタイム

Wasmを動かすためのランタイムとしてはWasmerWasmtime が有名ですが、どちらもRustで書かれています。上述したように自分のOSSはGoで書かれているためGoで使えるランタイムを探していました。Go用の Wasmer Go なども見つけたのですが、CGOを使っており絶対にCGOを使わないポリシーを持つ自分のOSSでは採用できませんでした。

そうしてしばらく探していたところTetrateにより開発されているwazeroが見つかりました。

github.com

CGOを使わずにPure Goで書かれています。さらに凄いことに依存ライブラリも0なので go.mod/go.sum が空です。まさに探していたプロジェクトなので導入を決めました。まだバージョン1.0.0に到達していないので破壊的変更が入ることもありますが、ソースコードもそこまで巨大ではないので自分で読んで何とか出来るサイズなのも嬉しいです。日本人の方( @mathetakeさん )が開発しています。世界に貢献していて凄いですね。READMEにも書かれていますが、元々個人で開発されていたOSSを会社に移しています。

TinyGoのExample

wazeroにはTinyGoとRustのサンプルが付いています。

github.com

今回はTinyGoのサンプルを見てみます。ただコードは変更される可能性があるので基本的にはGitHubを参考にしてください。

ランタイムの初期化

詳細はコードを直接見てもらったほうが早いですが、呼び出す側は以下のようにランタイムを初期化します。

// Create a new WebAssembly Runtime.
r := wazero.NewRuntimeWithConfig(wazero.NewRuntimeConfig().
    // Enable WebAssembly 2.0 support, which is required for TinyGo 0.24+.
    WithWasmCore2())
defer r.Close(ctx) // This closes everything this Runtime created.

以下のように logString というGoの関数を log という名前でWasm側にexportすることも出来ます。なおエラー処理はブログ内では全て省略しています。

// Instantiate a Go-defined module named "env" that exports a function to
// log to the console.
_, err := r.NewModuleBuilder("env").
    ExportFunction("log", logString).
    Instantiate(ctx, r)
}

関数の実行

あとはコンパイルされたWasmモジュールを受け取ってexportされた関数を取得できます。以下では greet という名前でWasm側からexportされている関数を呼び出せるように取得しています。

mod, err := r.InstantiateModuleFromBinary(ctx, greetWasm)
if err != nil {...}

// Get references to WebAssembly functions we'll use in this example.
greet := mod.ExportedFunction("greet")

あとはこの greet を呼び出すわけですが、引数としてstringを渡したいとします。Wasmは関数の引数として直接文字列を受け取ることは出来ないので、Linear Memory (または単にMemory)と呼ばれる任意のバイト列を書込み可能な領域に値を書き込んでそのポインタとサイズを渡します。

// Instead of an arbitrary memory offset, use TinyGo's allocator. Notice
// there is nothing string-specific in this allocation function. The same
// function could be used to pass binary serialized data to Wasm.
results, err := malloc.Call(ctx, nameSize)
if err != nil {...}

namePtr := results[0]
// This pointer is managed by TinyGo, but TinyGo is unaware of external usage.
// So, we have to free it when finished
defer free.Call(ctx, namePtr)

// The pointer is a linear memory offset, which is where we write the name.
if !mod.Memory().Write(ctx, uint32(namePtr), []byte(name)) {...}

コメントに書いてありますが、Wasmを呼び出す側が適当にメモリを確保するよりはTinyGoのメモリアロケータを使うほうが良いようです。自分でメモリを管理するのは大変ですし合理的な感じがします。実際TinyGoは mallocfree をexportしてくれているのでこれを使えるのですが、ドキュメントに記載されていない闇関数とのことです。全然関係ないですが遊戯王ど真ん中世代なので高橋和希先生の訃報は悲しかったです。

github.com

何にせよ上の例では malloc にサイズを渡してメモリを確保し、そのポインタにバイト列(ここでは文字列)を書き込んでいます。あとはそのポインタとサイズを greet に渡すだけです。

// Now, we can call "greet", which reads the string we wrote to memory!
_, err = greet.Call(ctx, namePtr, nameSize)

次にこのポインタとサイズを受け取るWasm側を見てみます。

//export greet
func _greet(ptr, size uint32) {
    name := ptrToString(ptr, size)
    greet(name)
}

ptrToString を呼んでstringに変換してそれをgreetという関数で表示しています。greet内は単にホスト側のロギング関数を呼んでいるだけなので、 ptrToString の中を見てみます。

// ptrToString returns a string from WebAssembly compatible numeric types
// representing its pointer and length.
func ptrToString(ptr uint32, size uint32) string {
    // Get a slice view of the underlying bytes in the stream. We use SliceHeader, not StringHeader
    // as it allows us to fix the capacity to what was allocated.
    return *(*string)(unsafe.Pointer(&reflect.SliceHeader{
        Data: uintptr(ptr),
        Len:  uintptr(size), // Tinygo requires these as uintptrs even if they are int fields.
        Cap:  uintptr(size), // ^^ See https://github.com/tinygo-org/tinygo/issues/1284
    }))
}

Goのsliceはdata, len, capで構成されているので受け取ったポインタとサイズをそれらに代入しています。ちなみにLenとCapはintで定義されているにも関わらず uintptr を渡しているのでGoだとビルドできません。逆にTinyGoでWasmビルドする時は uintptr じゃないとダメでした。

今回はstringを渡しましたが、適当なオブジェクトをシリアライズしてバイト列にして渡す際も同様にポインタとサイズを渡すだけなので、この方法で任意の型を渡せます。受け取る側はデシリアライズすればOKです。

別の渡し方

上の例では

  • ホスト側から malloc を呼び出す
  • その領域に値を書き込む
  • Wasm側にポインタとサイズを渡す

という順序で処理を行いましたが、

  • Wasm側でメモリを確保する
  • ホスト側の関数にそのポインタを渡す
  • ホスト側で値を入れて返す

という方法も使われるようです。GitHubに転がっているサンプルをいくつか見てるとこっちのほうが多い感じもします。以下はProxy-Wasm SDK Goの例です。

var raw *byte
var size int
st := internal.ProxyDequeueSharedQueue(queueID, &raw, &size)
if st != internal.StatusOK {
    return nil, internal.StatusToError(st)
}
return internal.RawBytePtrToByteSlice(raw, size), nil

proxy-wasm-go-sdk/hostcall.go at bc850cd5e90fe2984b12e025e2614dba37ac46ff · tetratelabs/proxy-wasm-go-sdk · GitHub

この例では rawsize のポインタをホスト側に渡し、値を入れて返してもらったものを []byte に変換しています。

事前に長さを知った上で malloc を呼び出してメモリ確保できるので前者の方法の方が効率的に見えるのですが、あまりこの辺の差は分かってないです。

Zigの実装

ではこのTinyGoのサンプルを参考にZigで実装してみます。

ちなみにZig初心者のくせにせっかく作ったしwazeroにPR出しちゃおう、と無邪気に投げたところZigのコミッタの方がフィードバックを下さり、自分の書いたものより圧倒的に良くなりました。初心者のクソコードをレビューしてくださるなんて感謝しかないです。こういう事が起こるので、初心者だし...と恐れず行動してみるのが大事です。OSSメンテナのレビューの手間を増やすなという意見もあるかもしれませんが、別にダメならメンテナはクローズするだけなので申し訳ないとか思わずに飛び込んでみて良いと思っている派です。

github.com

注意点としてはWASIではないという点です。上のPR内のコメントを見てもらえれば分かるのですが、WASIだとまた色々と異なるのでいずれPRを出そうと思います。いくつかハマりどころがあるので元気があればブログも書きます。

malloc

自分の見た限りZigでは malloc 等はexportされていなさそうなので自分で定義します。 exportfn の前に付けるとexport出来るようです。

pub export fn malloc(length: usize) ?[*]u8 {
    const memory = allocator.alloc(u8, length) catch return null;
    return memory.ptr;
}

この allocatorstd.heap.page_allocator を使っているのですが、targetがWasmの場合は WasmPageAllocator に切り替わるように見えます。Zig歴4日目なので嘘かもしれません。ziglearnを読んでた時間も含めると6日目ぐらいです。

zig/heap.zig at ae16c1d0835b517cb858cb9615888cdab1e351c5 · ziglang/zig · GitHub

つまり allocator で適当にメモリを確保すればWasmのMemoryをいい感じに使ってくれるので、普通のZigプログラムを書くのと同じノリで行けるのかなと理解しています。ただZig歴4日目なので(略)

ちなみにこの memory.ptr を返すのが大事で、memory 変数自体は恐らくスタックに確保されるので &memory のようにしてしまうと関数を抜けたタイミングでmemory変数は消されてしまって不安定な挙動になります。不安定と言ったのは -O ReleaseSafe とかだと動くのに -O Debug にするとpanicになっていたためです。Debugの方が積極的にスタックを消すのでしょうか。ちなみにこれは困っていたら@mattn_jpさんが助けてくださいました。

以前マスタケさんにZigのProxy-Wasm SDKを教えてもらっていたのでコードを読んでいて、何で memory.ptr にしてるんだろうと思ったのですがZig歴1日目だったので適当に &memory にしてガチャガチャやっていて死んでいました。以下はProxy-Wasm Zig SDKの例です。

pub export fn proxy_on_memory_allocate(size: usize) [*]u8 {
    const memory = allocator.alloc(u8, size) catch unreachable;
    return memory.ptr;
}

proxy-wasm-zig-sdk/memory.zig at dedb518a58bc766e092aa52254c1f7aef73caddd · mathetake/proxy-wasm-zig-sdk · GitHub

このサンプルもマスタケさんが書いたもので、自分がZigの調査を始めた時に教えて下さいました。「まさかZigのSDKも...?」「あるよ」とHEROのバーのマスターを思い出しました。*3

また、自分のオリジナルのコードは以下のように @ptrToInt を使ってusizeを返していました。

pub export fn malloc(size: usize) usize {
    var memory = allocator.alloc(u8, size) catch unreachable;
    return @ptrToInt(memory.ptr);
}

ただポインタはただのメモリオフセットでしかないから [*]u8 を戻り値にすれば良いと教えていただき @ptrToInt を使わない形になりました。Wasmには [*]u8 という型はないので(裏側ではusizeと同じだとしても)コンパイル時に怒られるのかなと思っていたのですが、Wasmをターゲットにしても特に問題なしでした。

free

以下のようになります。

pub export fn free(buf: [*]u8, length: usize) void {
    allocator.free(buf[0..length]);
}

こちらも最初はusizeを受け取って @intToPtr していたのですが、 [*]u8 で受け取ってslicingすれば良いと教えてもらって今の形になりました。

さらに以前は [*]u8free に渡せばいいかと思ったのですが怒られました。

/usr/local/Cellar/zig/0.9.1_1/lib/zig/std/mem.zig:2755:9: error: expected []T or *[_]T, passed [*]u8
        @compileError("expected []T or *[_]T, passed " ++ @typeName(sliceType));

ptrToString

最初は先程のTinyGoの例と同様、ポインタとサイズを受け取って []u8 に変換していました。以下は自分のオリジナルコードです。

pub fn ptrToString(ptr: u32, size: u32) []u8 {
    var s: []u8 = undefined;
    s.ptr = @intToPtr([*]u8, ptr);
    s.len = size;
    return s;
}

ただ、こちらも上のfreeの例同様、 [*]u8 を引数にしてしまえばslicingだけでstring( []u8 )になるので不要と教わりました。修正後は以下になります。

pub export fn greet(message: [*]const u8, size: u32) void {
    const name = _greeting(message[0..size]) catch unreachable;
    _greet(name);
}

messageの型が [*]const u8 になっていて、それを message[0..size] のようにslicingすることで []u8 に変換しています。とてもシンプルに書けて最高です。

stringToPtr

上の ptrToString とは逆でZig内の文字列をポインタとサイズに変換してホスト側に返します。

// stringToPtr returns a pointer and size pair for the given string in a way
// compatible with WebAssembly numeric types.
pub fn stringToPtr(s: []const u8) u64 {
    const p: u64 = @ptrToInt(s.ptr);
    return p << 32 | s.len;
}

u64を返している理由ですが、WebAssembly 1.0では一つしか戻り値を返せないためです。

In the current version of WebAssembly, at most one value is allowed as a result. However, this may be generalized to sequences of values in future versions.

Types — WebAssembly 1.0

WebAssembly 2.0では複数の戻り値も返せるはずですが、まだDraftですし今回は1.0準拠で行きます。

Result types classify the result of executing instructions or functions, which is a sequence of values, written with brackets.

Types — WebAssembly 2.0 (Draft 2022-08-04)

ポインタとサイズの2つを返したいのに返せないので、u64に2つのu32を入れて返しています。ちなみに上の例で一旦u64の変数に入れてからじゃないと << 32 は動きませんでした。

@ptrToInt(s.ptr) << 32 | s.len;

上のように書くと以下のようなエラーが出ます。

./src/main.zig:68:32: error: integer value 32 cannot be coerced to type 'u5'
    return @ptrToInt(s.ptr) << 32 | s.len;

ビットシフトしたい変数の型により制限を受けるようです。バグのような感じがしますが、とりあえず一旦大きい型を持つ変数に入れてあげたら動きました。

github.com

呼び出し側

基本的にTinyGoで見たサンプルコードと同じで動きます。

_, err = greet.Call(ctx, namePtr, nameSize)

サンプル

ここまで歯抜けで説明してきたので何の話をしているか意味不明だったかもしれないのですが、コード全体を見れば普通に理解できると思います。上のPRを取り込んでもらったので以下でコード全体を読むことが出来ます。

github.com

まとめ

GoのWasmランタイムであるwazero上で、Zigで書いたコードをWasmにコンパイルして動かしてみました。単なるHello worldレベルではなくメモリを確保してsliceをGo<=>Wasmで相互にやり取りするというところまで確かめました。上述したように任意のオブジェクトをシリアライズすればやり取りできるので大体のことは何とかなります。ここまで出来ると大分実用的な気がします。

正直Zigは始めたばかりですしWasmも特別詳しいわけでもないのでエアプなブログを書いている気がしますが、せっかくZigのコミッタやWasmランタイムのメンテナ達からレビューしてもらったので初学者の悪戦苦闘の記録として公開しておきます。Twitterでプロ開発者に助けてもらったりもしたので、このサンプルを書くためだけに多くの人に手助けしてもらっています。まさかり投げられそうで怖いですが、オープンに活動すると誰かが助けてくれたりして色々捗るのでおすすめです。

Zigは始めたばかりなのに思ったよりハマりどころが少なくて良かったです。言語仕様がコンパクトなところも気に入っています。以下は精神年齢が小学生で止まっているのですぐ漫画の話をしてしまう図です。

*1:実際には既にプラグインは別の機構があったので苦肉の策でモジュールと呼んでます

*2:WebAssembly 2.0のドラフトでもその辺は変わっていなさそうに見えますが、ちゃんと追ってないので今度ちゃんと読みます

*3:今の若者は知らなさそう https://ciatr.jp/topics/44857