Unity/.NETでWebAssembly式スクリプトエンジンを作る
Unityで動くWebAssemblyランタイムを作ると、スクリプトエンジン(会話とか用)として使いやすいんじゃないかというアイデア。今回のスコープではないけど、UGC用途もわりとアリかも。
先行実装
- dotnet-webassemblyという、C#で書かれたWebAssemblyランタイムがあったので見てみたけど、wasm -> IL -> JITという感じだった。速度出そうだしなかなか楽しそうなアプローチだ
- 今回は後述の通り、AOTセーフで行くのでこれはパス
- インタプリタつき。よさそうだけど更新されてない
wasmにコンパイルできるいろんな言語が使える
- https://github.com/appcypher/awesome-wasm-langs
- わりとみんなRustでwasm書いてるけど、インスタントなスクリプト向きではないかも
- emscriptenとかbinaryenとかもあるので、自作言語書く下地が十分にあるのもよい
AOTセーフにする
- 今回はIL2CPPセーフにしておきたいので、動的コード生成はナシでインタプリタ式で行く
- 実行速度は出ないだろうけど、今回の用途で困ることはないでしょう
非同期処理を書けるようにする
- wasmで非同期を書きたい。
- wasm側からランタイムのAPIを同期的に呼ぶと、そこでランタイムが中断して、C#側のタスク完了を待つ、みたいな仕組みをつくれそう
バインディング
- C#/Unityとの連携のさせ方をどうするか
wasmとの相互運用について
WASI (WebAssembly System Interface)
一般的なシステムコールをwasmから呼び出すためのAPIセット。実装しといてもいいかもしれない
WebAssembly Component Model
IDL (WIT) で関数やデータ構造を定義して、各言語向けのヘッダファイルを生成したりするツールチェインを利用する。ただ現状はまだ対応言語少なめ(Rust/C/C++/Java/Go)。
とりあえず純粋なwasmランタイムを実装する
コア仕様にはv1.0と、v2.0のドラフトがあり、v2.0では命令もいろいろ増えている。
ひとまずv1.0だけ実装して動くところまでもっていく
ひととおりの命令や実行システムを実装したので、次はテスト。
コア仕様のテストスイートが公開されているので、これを通していく
スタックサイズについて
公式テストスイートの中に、スタックを溢れさせるテストがあった。
今回は非同期対応を視野に入れているため、中断ポイントでスタックの内容を保存しなければならず、必然的にスレッド自体のスタックは使わず、別途ヒープに確保した領域をwasmスタックとして使っている。
一般的なOSスレッドのスタックサイズは1MBとか2MBとかだけど、今回のようにヒープを使っていると、ほとんど無尽蔵にスタック領域を確保できてしまう。
どこかに限界を定めるべきだろうけど、どのくらいにしたものか……。
そもそもスタックはインスタンス化時点で決まったサイズを確保したところで、ほとんどのケースではそのうちごく少量しか使わないであろうから、必要に応じて伸長する方式のほうが望ましい。
wasmの関数は、ある命令の時点におけるスタックの深さ(と値の型)が一意に定まることになっているから、関数の実行に必要なスタックの大きさは事前に計算することができる。また、関数を横断してスタックの値を参照することはないから、スタックは必ずしも連続している必要はない。関数=フレームごとに細切れの(2の累乗サイズとかの)スタックを確保し、それらを連結リストで保持するのがよいかもしれない。
f32.convert_i64_u
のテストがコケる。テストケースだと9.0072E+15
だけど、C#的に素直に実装すると9.007199E+15
が返ってくる……。これは浮動小数点数の丸め方が違うのか
9.0072E+15
が0x5A000000
で、
9.007199E+15
が0x5A000001
wasmの浮動小数点数丸めは「最近接偶数への丸め (Round to nearest, ties to even)」
Rounding always is round-to-nearest ties-to-even, in correspondence with [IEEE-754-2019] (Section 4.3.1).
https://www.w3.org/TR/wasm-core-1/#rounding①
C# (.NET CLI) は、.NET Core 2.1以前・以後で挙動が違う?
精度指定子によって結果文字列内の小数部の桁数を制御する場合、結果文字列では無限に正確な結果に最も近い表現可能な結果に丸められた数値が反映されます。 同じように近い表現可能な結果が 2 つある場合は、次のようになります。
.NET Framework および .NET Core 2.0 までの .NET Core の場合、ランタイムにより最下位の数字が大きい方の結果が選択されます (つまり、MidpointRounding.AwayFromZero が使用されます)。
.NET Core 2.1 以降の場合、ランタイムでは最下位の数字が同一である結果が選択されます (つまり、MidpointRounding.ToEven が使用されます)。
https://learn.microsoft.com/ja-jp/dotnet/standard/base-types/standard-numeric-format-strings
これだけ見ると、wasmと今の.NETで丸め挙動に差があるようには見えないけど……?
なんか、浮動小数点数 -> 整数 変換時の丸め挙動についてはいろいろ情報があるけど、逆についてはほとんどわからん
ところで、WASI / Wasm Component Model (WASI v0.2)については、AssemblyScript方面からいろいろ物言いがついているそうだ
Component Modelもちょっとずつ読み進めてるけど、確かにRustをかなり意識した設計っぽいのがわかる
まだテスト通してる途中だけど、バインディングについて考えてること
バインディング層は抽象化する
Component Model (WIT) みたいな相互運用標準を定める動きもあれば、AssemblyScriptみたいにホスト側とのデータの交換のために必要なAPIを独自に定めている例もある。せっかくwasmをやってるんだから、できるだけ特定言語へのロックインは避けたく、バインディング実装は交換可能にしたい。
boxing / Reflectionは回避する
いくらインタプリタで実行速度が出ないとはいっても、wasm呼び出しのたびにGC.Alloc出るとテンションが下がるので、boxingは避け、避けられぬメモリ確保もできるだけプーリングで回避する。
素朴に実装すると、C# -> wasmの呼び出しはこんな感じになってしまうところであるが、
ValueTask<object> CallWasmFunction(FunctionInstance function, params object[] parameters);
これは最近Unity Loggingのソースコードで見た、Source Generatorで引数の型を静的に決定させるテクニックで回避できそう。メソッド呼び出しを書くとSource Generatorが現れて、シグネチャが静的に決定したバージョンのオーバーロードを生やしてくれる。あとはそこでよしなにマーシャリングすればよい。
やっとwasm core v1.0テストが全部通った……
感想
- バリデーション、特にスタック状態のバリデーションがとにかく面倒だった
- ブロックによる制御フローは考慮事項が多くて大変
-
unreachable
命令が絡む検証はムズい
- 浮動小数点数が絡む計算はC#的に素直な書き方とwasmで細かい仕様が異なるものが多かった
- 一部は厳密なチェックを諦めました(つまりぜんぜん全部パスではない!)
- エラーメッセージの検証は面倒かつ本質的ではないのでスキップしました
- とりあえず有効なモジュールとそうでないモジュールが区別できればOKということに……
- 余裕出てきたらここもやる
- 先人の記事にいろいろなハマりポイントが書いてありとても助かりました
ということで、wasm core v1.0が動く純粋なwasmインタプリタができたので一段落。
このあとは、
- いくらか実際に自分で書いたコードをwasmにコンパイルして動かしてみて遊ぶ
- いったんコードの整理とリファクタリングする
- バインディングの実装に入る
Component Model と Canonical ABI
Binding APIの設計がわからなくなってきたので、先にComponent ModelとCanonical ABIななめ読み
lift と lower
component modelでは、core仕様準拠のモジュールの外側に様々メタデータを付与するバイナリフォーマットを用いている。そのメタデータの中では、コンポーネントでの関数の呼び出しに際し、コア仕様の基本的なデータ表現と、Component Modelでのリッチなデータ表現を交換するためのアノテーションliftとlowerが記述される。
-
lift
: core関数をラップして、Componentレベルの関数定義を作成する -
lower
:Component関数をラップして、モジュール内部のコードから呼び出せる関数定義を作成する
具体的なliftとlowerの方法はここ
flattening
flat
という文字が見えるけど、おそらくパラメータ数がある程度まではwasm上もパラメータとして渡すが、パラメータが多い場合はいったん線形メモリに展開してポインタを渡すということだろう
パラメータの場合、フラット化の閾値は16。
戻り値の場合は1(将来変わるかもと書いてある)
Component Modelのそれぞれの型に対して、フラット化が定義されている:
def flatten_type(t):
match despecialize(t):
case Bool() : return ['i32']
case U8() | U16() | U32() : return ['i32']
case S8() | S16() | S32() : return ['i32']
case S64() | U64() : return ['i64']
case F32() : return ['f32']
case F64() : return ['f64']
case Char() : return ['i32']
case String() | List(_) : return ['i32', 'i32']
case Record(fields) : return flatten_record(fields)
case Variant(cases) : return flatten_variant(cases)
case Flags(labels) : return ['i32'] * num_i32_flags(labels)
case Own(_) | Borrow(_) : return ['i32']
flatten_record()
は各メンバをシーケンシャルにフラット化してつなげる。
flatten_variant()
(Rustでいうenum)はすべてのcaseをカバーできるようにしている