自作Wasmランタイムを公式のテストスイートを用いてテストする話
概要
私が開発している自作Wasm Runtimeでは、公式のテストスイートを用いてテストを行っている。本記事では自作Wasm Runtimeに取り組んでいる、または取り組もうとしている人達に向けて、公式のテストスイートを用いて自作Wasm Runtimeをテストする方法を紹介する。この記事はWebAssembly Advent Calendar 2023五日目の記事である。
方法
公式のテストスイートはwastという形式になっており、以下で定義されている。
これをwabtに含まれるwast2jsonに入力すると、必要なwasmバイナリとjsonファイルが生成される。テストする際は、このjsonファイルをパースして内容に応じたテストを行う。出力されるjsonの内容はwast2jsonのドキュメントで定義されている。
方針
テスト可能な実装にする
当然ながらテストを行うためにはテスト可能な実装にする必要がある。自作Wasm Runtimeでは失敗する可能性のある関数は全てerror_t型を返すようになっており、発生したエラーをきちんと呼び出し元に通知するようになっている。
自作Wasm Runtimeに取り組む際、最初は簡単のためにすべて正しく動くことを前提として作りたくなるだろうが、後からエラー処理を加えるのは難しいため、最初からエラー処理を考えて設計することを薦める。
エラーメッセージの検証は外で行う
assert_invalidやassert_malformedといった異常系テストの場合、エラーメッセージが一致することをテストする必要がある。しかしメッセージは本質ではないため、自作Wasm RuntimeではRuntimeはエラーコードを返すだけで、テストプログラム側でエラーコードとエラーメッセージを対応させるという設計にしている。
また、エラーメッセージの中にはelem.wastの以下の部分のように、動的に変わるものがあるため、完全一致ではなく部分一致で検証している。
自作Wasm Runtimeをフルスクラッチで開発する際は、最初ERR_FAILEDのような一般的なエラーコードを返すようにして、テストを通す段階で新しいエラーコードを定義すると良いと思う。
通す順番
以下の記事にも書いた通り、公式のテストスイートは命令ごとに綺麗に分かれていない。そのため、一つ一つ順番に通すことはできず、ある時点になると突然沢山のテストが通るようになる。
call_indirect命令のサポートが難しいため、制御命令のテストは後回しにし、まずNumeric Instructionsに関するテストを通すとよい。関数やテーブルのImportを考えると複雑になるため、Importのサポートが必要なテストは最後に通すことを勧める。以下のファイルの内容が(基本的には)私がテストを通した順番になっているので参考にして欲しい。call_indirect.wast, linking.wast, binary.wastの三つが特に難しいと感じた。
注意点
浮動小数の扱い
wast2jsonのドキュメントに書かれている通り、浮動小数はjsonに変換されるとそれを10進数で表現した値になる。例えばf32.wastの以下のテストケースをwast2jsonで変換すると、以下のようになる。
{"type": "assert_return", "line": 19, "action": {"type": "invoke", "field": "add", "args": [{"type": "f32", "value": "2147483648"}, {"type": "f32", "value": "2147483648"}]}, "expected": [{"type": "f32", "value": "2147483648"}]},
そのため、jsonをパースしてf32の値を得る箇所は以下のようになる。
また、Wasm SpecではCanonical NaNとArithmetic NaNという二種類のNaNが定義されており、いくつかのテストケースでassert_returnの返り値に指定されている。
Canonical NaNの場合は「canonN is a payload whose most significant bit is 1 while all others are 0」とあるので、0x7fffffffとandをとって0x7fc00000と一致するか調べればよく、Arithmetic NaNの場合は「canonN, such that the most significant bit is 1 while all others are arbitrary」とあるので、0x00400000とandを取って1になるか調べればよい。(f32の場合)。これはwasmerの実装を参考にした。
spectest.wasm
data.wastやglobal.wastといったテストの中で使用されるモジュールには、"spectest"モジュールからのインポートを含むものがある。
このspectestモジュールはどのテストにも含まれておらず、以下のドキュメントに定義がある。
ただ、グローバル変数の初期値は明記されていないため、spec intepreterの実装を読むかテストケースから推測する必要がある。自作Wasm Runtimeでは以下を用いている。
spec interpreterの実装に依存したテストケース
テストケースの中にはspec intepreterの実装に依存したものがいくつか存在する。分かりやすいのはbinary.wastの以下の部分である。
コメントにあるとおり、spec intepreterはdata count sectionのsection idである0xbをend命令として消費する。そのため、code sectionのサイズが合わず(6と定義されているのに7になるため)エラーとなる。このテストケースは明らかにspec interpreterの実装に依存したものになっている。私が実装したRuntimeでは定義されたsection sizeを超えて読むことはなく、この入力の場合はERR_END_OPCODE_EXPECTEDというエラーコードを返す。(code sectionの最後が0xbになっていないため)そのため、自作Wasm Runtimeのテストに使用しているテストスイートは公式のものから多少変更を加えたものになっている。
wast2json
wast2jsonが生成するWasmバイナリが、テストケースが期待しているものとは異なることがある。例えばselect.wastには以下の二つのテストケースが存在する。違いは(result)があるかないかである。
しかし、これをwast2jsonに入力すると同じWasmバイナリ(select.1.wasmとselect.2.wasm)が生成されてしまう。二つ目のエラーメッセージが"invalid result arity"であることと、select命令のValidationの定義より、これはselect(0x1c)かつ、vectorの長さが0であるものが出力されることを意図していると推測できる。そのため、select.2.wasmだけは手動で定義したものをテストに用いている。
さらに、memory_init.wastには以下の二つのテストケースが存在する。
それぞれdata, memoryが定義されていないため、このテストは正しいと考えられる。しかし、binary.wastにはこれらとほとんど同じテストケースが存在しており、ここではdata count sectionを持っていないことが理由ではじかれている。
問題なのは、memory_init.wastをwast2jsonに入力するとdata count sectionを持たないWasmバイナリが生成されてしまうことである。この場合、binary.wastのテストケースと同様にDecodeに失敗する。assert_invalidはValidationで失敗すること期待しているため、このままではmemory_init.wastを通すことができない。私は当初、これをテストの間違いだと考えてissueを立てたのだが、これはテストの間違いではなく、wast2jsonがdata sectionが定義されていない場合にdata count sectionを生成しないことが原因だと分かった。そのため、memory_init.wastは該当箇所をコメントアウトして用いている。
おわりに
公式のテストスイートを用いてテストするのは難しい反面、仕様の勉強になる。インクリメンタルな開発には向かないため、ランタイムをある程度実装してから用いるのが良いだろう。
Discussion