ZigでWeb Assemblyのインタプリタを書いてみた

プログラミング言語Zigを使ってWebAssembly (Wasm) のインタプリタを書いてみました。SIMD命令以外のテストは通っているので、Core Specification version 1.0の範囲はサポートできているはずです。

作ったインタプリタの紹介

作ったインタプリタはGitHubで公開済みなのでクローンして試すことができます。クローン後、Zigを導入済みであれば zig runで実行できます。

試しに2つの整数の足し算関数addを含む次のようなWasmバイナリを実行させてみましょう。まず、wasm-objdumpでバイナリの中身を確認してみます。2つの整数を取り1つの整数を返すaddという関数があり、それが単に足し算をしているだけであるのがわかります。

❯ wasm-objdump -dx add.wasm


add.wasm:   file format wasm 0x1


Section Details:


Type[1]:
- type[0] (i32, i32) -> i32
Function[1]:
- func[0] sig=0 <add>
Memory[1]:
- memory[0] pages: initial=16
Global[1]:
- global[0] i32 mutable=1 - init i32=1048576
Export[2]:
- memory[0] -> "memory"
- func[0] <add> -> "add"
Code[1]:
- func[0] size=7 <add>


Code Disassembly:


00003b func[0] <add>:
00003c: 20 01                      | local.get 1
00003e: 20 00                      | local.get 0
000040: 6a                         | i32.add
000041: 0b                         | end

これをインタプリタで実行させてみましょう。42と35の加算結果の77が返されているのがわかります。

❯ ./zig-out/bin/zig-wasm-interp add.wasm -r add -a 42 -a 35
=> { 77_i32 }

ZigではWASI向けのバイナリも作れます。WASI向けのバイナリとしてこのWasmインタプリタをコンパイルしてWasmのバイナリを作ってみましょう  (zig buildではspec_testバイナリのコンパイルには失敗しますが、インタプリタのコンパイルには成功します)。

❯ zig build -Dtarget=wasm32-wasi -Doptimize=ReleaseSmall
zig build-exe spec_test Debug wasm32-wasi: error: the following command failed with 1 compilation errors:
   :
Build Summary: 2/5 steps succeeded; 1 failed (disable with --summary none)
install transitive failure
└─ install spec_test transitive failure
  └─ zig build-exe spec_test Debug wasm32-wasi 1 errors
/Users/mac/.local/share/mise/installs/zig/0.11.0/lib/std/fs.zig:1513:13: error: realpath is not available on WASI
           @compileError("realpath is not available on WASI");

Wasmが生成されているフォルダに移動してZigで書かれたWasmインタプリタを、WASIが使えるWasmランタイムWasmtimeで動かしてみた結果がこちらです。通常のバイナリと同じ結果が返されました。Wasmバイナリのサイズを確認したところ229KBでした。

❯ cd zig-out/bin
❯ ls -lh *.wasm
-rwxr--r--  1 nulab  staff   229K  3 11 22:47 zig-wasm-interp.wasm*


❯ wasmtime run --mapdir=.::. zig-wasm-interp.wasm -- add.wasm -r add -a 42 -a 35
=> { 77_i32 }

Zigについて: よい点・興味深い点

comptime

comptimeはコンパイル時に値が確定するものであることを指定するためのキーワードです。他の言語でも同様の概念はありますが、Zigではそれをユーザーが自分で指定することができます(ざっくり言うとcomptimeはC++のtemplateとconstexprを組み合わせたようなものです)。

fn max(comptime T: type, a: T, b: T) T {
   return if (a > b) a else b;
}

この関数maxを次のように呼び出すことができます。

const x = max(i32, 42, 192);   // 192
const y = max(f32, 4.5, 3.2);  // 4.5

comptimeのいいところはZigのコードの中で自然な形で使えることです。引数の型や戻り値の型に対してもcomptime-known (「コンパイル時に値が確定している」ということを、Zigではこう呼びます) な値を返す関数を呼ぶことができるのでこういうこともできます。

fn UnsignedTypeOf(comptime T: type) type {
   return switch (T) {
       i32 => u32,
       i64 => u64,
       else => unreachable,
   };
}


fn foo(comptime T: type, x: UnsignedTypeOf(T)) UnsignedTypeOf(T) {
   const num: UnsignedTypeOf(T) = @bitSizeOf(T);
   return num * x;
}

これ以外にも、comptimeなvarやcomptimeなblockを使えたり、

comptime var x = 0;
comptime var res = 0;
inline while (x < 10) : (x += 1) {
   res += x;
}


const res2 = comptime blk: {
    var y = 0;
    var tmp = 0;
    while (y < 10) : (y += 1) {
        tmp += y;
    }
    break :blk tmp;
};


std.debug.print("res={} res2={} {}\n", .{ res, res2, xx }); // res=45 res2=45

@typeInfoをはじめとするコンパイル時型リフレクションが使えたり、inline for, inline else, inline while でのインライン展開ができたりします (詳しくはマニュアルを参照してください)。

自作のWasmインタプリタでは例えば、binopの実装に使ったりしています。仕様通りにtの値をスタックから2つpopして取り出して計算した結果をスタックにpushする…というのをcomptimeを使って型Tと関数fを引数で取るようにすることで実現しています。

inline fn binOp(self: *Self, comptime T: type, comptime f: fn (type, T, T) Error!T) Error!void {
   const rhs: T = self.stack.pop().value.as(T);
   const lhs: T = self.stack.pop().value.as(T);
   const result = try f(T, lhs, rhs);
   try self.stack.pushValueAs(T, result);
}

このbinOpを使って、i32.addとi64.addは

.i32_add => try self.binOp(i32, opIntAdd),
.i64_add => try self.binOp(i64, opIntAdd),

のように呼び出しています (opIntAddは次のような加算結果を返すだけの関数) 。

fn opIntAdd(comptime T: type, lhs: T, rhs: T) Error!T {
   return lhs +% rhs;
}

余談ですが、ちなみに私がZigに興味を持ったのは昨年のP99 ConfのBunの開発者、Tokioの開発者、libSQLを開発しているTursoのCEOによるRustとZigについてのパネルディスカッションで、「comptimeは最高だ。他の言語にも欲しい (意訳)」みたいな発言があったからだったりします。

エラー処理

Result型みたいなものを使ったエラー処理をZigでも行えます。ZigではResult型みたいなものを正常時の型とエラーの共用体として表現し、これをエラーユニオンと呼んでいます。

シンタクスシュガーも用意されていて、! の右側に正常時の型、左側に取り得るエラーを書くことでエラーユニオン型を表現できます。

ParseFloatError!f32

ちなみに左側は省略できて、例えば!f32と書いたときは暗黙でanyerror!f32であると見なします。anyerrorは全エラーの集合であり、どんなエラーも起こり得るということを表します。

Zigでエラー処理をするにはif, try, catchなどを使います。例えばifは成功時にはthen節、エラー時にelse節が処理されます。

// fn foo() !f32 を呼ぶ
if (foo()) |val| {
    std.debug.print("Ok: {}", .{val});
} else |err| {
    std.debug.print("Err: {}", .{err});
}

tryとcatchは、それぞれRustで言うところの ? と unwrap_or_else に相当します (tryはZigのコード的には catch |err| return err; 相当)。

自作のWasmインタプリタでは基本的にtryしか使っていません。catchは、エラー種別の書き換え、もしくはエラー時のスタックの処理に使っています。

ありがたい機能として、エラーUnionを無視するような書きかたをするとエラーになる点があります。

src/main.zig:19:8: error: error is ignored
   foo();
   ~~~^~
src/main.zig:19:8: note: consider using 'try', 'catch', or 'if'

エラー処理を忘れてもコンパイラが指摘してくれるので安心です。

union と union(enum)

いわゆる代数的データ型っぽいものをZigではTagged unionと呼び、enumとunionの組み合わせとして定義します。

pub const Value = union(enum) {
   tuple: struct { a: i32, b: i32, c: i32 },
   pair: struct { i32, i32 },
   singleton: i32,
};

tupleなどは使えない…と思っていたのですが、キーワードstructを付けたら使えるみたいです (この記事を書くときに気づきました…)。

使うときはこんなかんじで使います (書きかたに癖がありますがしばらくすると慣れました)。

const z1 = Value{ .singleton = 9 };
const z2 = Value{ .pair = .{ 4, 8 } };
const z3 = Value{ .tuple = .{ .a = 4, .b = 8, .c = 9 } };

戻り値や引数などですぐにわかる場合は型を省略することができます (anonymous struct/tuple)。

const y1: Value = .{ .singleton = 9 };
const y2: Value = .{ .pair = .{ 4, 8 } };
const y3: Value = .{ .tuple = .{ .a = 4, .b = 8, .c = 9 } };

Wasmインタプリタでは型の情報のみが欲しい状況と型と値の情報が欲しい状況があったので、enumとunionを分離してそれぞれValueTypeとValueとして次のように定義しています。

pub const ValueType = enum(u8) {
   i32,
   i64,
   f32,
   f64,
   func_ref,
   extern_ref,
};


pub const Value = union(ValueType) {
   i32: i32,
   i64: i64,
   f32: u32,
   f64: u64,
   func_ref: ?FuncAddr,
   extern_ref: ?ExternAddr,
};

数値の扱いが厳密

Zigでは、例えばu16からu8への変換のように、情報が欠損する可能性のある場合には明示的なキャストが必要です。

型キャストを行う組み込み関数も多くて最初はどれを使えばよいのか戸惑いました。+ 演算子などもオーバーフローを起こすので、オーバーフローを考慮するなら@addWithOverflowを使う必要があります。

数値操作については、演算子、組み込み関数、std.mathの関数など似ているものが3つが用意されていることもありややこしいです (演算子は他の言語にはない +% や +| みたいなのもあったりする)。

よい点のほうに書きましたがWasmインタプリタを書くときに苦労した点でもあります。

パッケージ管理

公式のパッケージマネージャーが2023年8月にリリースされた0.11.0から使えるようになっています。ただし、npmjs.comやcrates.ioに相当するパッケージリポジトリは存在しないので、npmやcargoみたいにパッケージをコマンドから追加するといったことはできません。

build.zig.zonに手動でごりごり書いていくスタイルです (こんなかんじです)。今回Wasmインタプリタを実装する際には依存はなしにしようと思っていたのでちゃんと使っていません。利用しなかったので詳しくはわかりませんがC/C++のtar.gzのurlをbuild.zig.zonに書けたりするっぽいです。

Zigについて: 困った点

エラー処理

内部的には、Zigのエラーはu16の数値として扱われています。Rustや他の言語みたいにエラーの詳細な情報を返すことができません。なので、素直に書いているとエラーが起きたときはそっけないエラーメッセージだけ残してプログラムが終了する、みたいなことになります。

この点はやはり多くの人が困っているみたいで、日本語でもいくつか記事があったり (例えばこの記事)、GitHubにもIssueが挙がっていたりします。

おそらく戻り値のサイズを大きくさせないための工夫だと思いますが、実際にプログラムを書いてみると、やっぱり詳細情報を返せるほうが嬉しいなあと感じました (実際にエラーに情報を持たせたとして、メモリの開放はどうするのか、などの問題はありますが…)。

Allocator

Zigのお作法的にはAllocatorを外から指定できるようにするのがよいみたいなので、内部でこっそりとAllocatorを使うのでなければ、だいたい引数に入れるかstrcutのメンバーに入れるかのどちらかになります。

今回Wasmインタプリタでは多くのstructにアロケータを持たせるようにしました (場所によっては手を抜いてArenaAllocatorを使ってメモリを一括開放したりしてる)。

ちなみにAllocatorがいろいろ用意されていてどれをどれを使えばいいのかわからない人向けにマニュアルに指針が書かれているので参考にしてください。

おわりに

Zig言語を使った感想を書いてみました。言語的にはそれなりにシンプルだけどcomptimeでパワフルなこともできるというバランスがよい印象で、個人的には書いていて楽しい言語ではありました。

会社の人に「Zigって何に使えるん?」と聞かれたので、思いついたことをいくつか挙げてみますと、まず、作られるバイナリが小さいらしいので、Wasmの作成に向いていそうに思います。最近エッジ環境でWasmを動かせるようになってきていますが、使えるバイナリのサイズに制限があるのでそういう状況にはZigは使えそうです。また、依存関係がないバイナリをつくれることから小さいDokcerコンテナを作ることや、組み込みなどに向いているかもしれません。後は (今回特に触れませんでしたが) Cとの親和性が高いので、Cのビルドツールとして使えたりするみたいです。

Wasmインタプリタについての紹介などは3/22 (金) に開催予定のGeeks Who Drink Osakaにて発表する予定ですのでよろしくお願いします (と言っても15分しかないので詳しいことは話さないと思います…)。

 

開発メンバー募集中

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

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

製品をみる