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をカバーできるようにしている
- Binding API
- C# <-> wasmのBoxing回避のためのSource Generatorの実装
- コルーチン
このへん実装ができたので、基本機能としてはだいぶそろってきた
あとはComponent Modelまわりの実装して公開かな……
マネージドオブジェクトの相互運用
UnityのAPIのバインディングをつくるなら、wasm側にC#オブジェクト(のハンドル)を渡せるようにしたいところ
.NETのマネージドオブジェクトはGCで移動されうるので、ポインタをwasmワールドに直に渡すわけにはいかないし、wasmワールドからの参照をトラックしないといけないので、マネージドオブジェクトの受け渡しにはなにかしらのハンドルを介する必要がある
- Reference Types (externref)
- 不透明な外部参照値で、ランタイムでトラックできる
- 言語側の対応状況がよくわからない(core spec v2のドラフトには入ってる)
- https://webassembly.github.io/spec/core/syntax/types.html#reference-types
- Component Model
- コンポーネント間で受け渡せる値の種類に「ハンドル」があり、コンポーネント側で不要になったタイミングでdrop処理が呼ばれる
- 参照カウンタ機構を作って、drop時にカウンタを減らす
- 実用上wit-bindgenが必要で、まだ対応言語が少ない
- https://github.com/bytecodealliance/wit-bindgen
- UnityEngine.ObjectのInstance ID
- UnityEngine.Object限定ならinstance idで参照トラックできなくもない
- ランタイム側でハンドルを追跡する
- wasmワールドに渡されたハンドルの行方を追跡し、値が変更されたり破棄された時点で参照が切れたとみなす
- 設計次第だが、線形メモリ上の値にラベルを付ける必要があるので、メモリ操作全体にわたってオーバーヘッドが生じるかもしれない
- ポインタに対する演算はないものと仮定する必要があるが、本当にそれは成り立つのか
上記の中では、(普及さえすれば)Component Modelが最良と思えるので、Component Modelで実装を進める。当面はRustくらいしか現実的な選択肢にならないかもしれないが、今後流行ることを祈って……
ということで、Component Modelの仕様をちゃんと読み始めた
バインディングはUnityCsReferenceなどからRoslynでAPI情報を抜き出してWITのインターフェース定義を生成し、それをもとにユーザーがコンポーネントを書くイメージ。
マネージドオブジェクトはC#側に参照カウンタテーブルをつくっておき、WASMからの参照が切れたらC#側も参照をはずす。
Compoennt Modelバイナリのパーサー実装がそれなりに進んで、cargo-componentで出したhello-worldなcomponentが全部読めるようになった
次はランタイム実装、とはいってもComponent ModelはCore Moduleにリッチな型のメタデータをかぶせたフォーマットにすぎないので、コンポーネント・モジュール境界でのデータ交換さえ実装すれば、それ以外にランタイムに手を加える必要は(ほぼ)ないはず
最後の問題はバインディングをどうするかで、Core Moduleのデータ型はたいして種類がなかったのでSpan<WasmValueType>
みたいなものでフラットに受け渡しができたが、Component Modelではそうはいかない。Component ModelにはWITがあるので、基本的にはWITから事前生成したバインディング実装を通して呼び出すのがパフォーマンス的にも開発体験的にも最良な気がする
でもWITのパーサー書くのめんどいな……
C# <-> wasm component境界の引数・戻り値の交換を行う仕組みはできた。あとはC#・wasm componentの双方から見て最も自然な形でFFIできるようにするためのバインディング生成部分をやっていく
ユースケース
WASI preview 2
例えばRustのwasm32-wasip1
やwasm32-wasip2
は、ユーザーの作成したWITとは無関係にWASIの機能をインポートする。よって今回は、WASI preview 2のWITからC#側のバインディングを生成する。これはWASI preview 2の機能を実装するための(ユーザーフレンドリーな)C#のinterface
のセットとなる。
さらに、それらのinterface
とFFI用のデータ変換等は生成されたバインディングコード側で行う。
witのパースはwit-bindgen
等の内部で使用されているwit-parser
があるのでこれを使う(つまりツールはRust実装)
wasm -> C#への機能の公開
これはユーザー定義のWITからC#コードを生成することになり、基本は上記のWASI preview 2と同様のinterface
を生成するが、加えてそれらのinterface
の呼び出しをwasmの呼び出しにつなぐ実装を生成する。
C# -> wasmへの機能の公開
これはC#コードをソースとして、対応するWITとinterface
を生成するCLIツールを作成する。C#コードの解析が必要なのでRoslynを利用する。
いろいろな悩みポイント
Unity用バインディングの生成
wasm側でC#やUnityのフル機能が使えるとアツいが、生成コード量が膨大になる。また、生成されたコードはwasmからいつ呼ばれるか事前に判断できないので、すべてをManaged Code Strippingの対象から除外せざるを得ず、コードサイズが増えすぎる懸念がある。あと、これ系の大量コード生成をやるとビルド時間(主にManaged Code Stripingの時間)が伸びてつらいので避けたい。
ということで、必要な機能だけを選択的に、かつ手軽に処理できるとよい。
いまのところはクラス単位でユーザーが指定を行い、ツールがクラスに含まれる各APIに対するコード生成を行い、副次的に必要になる他の型は型定義だけ生成するといった感じで考え中。
リソースの扱い
Component Modelでは不透過な外部参照を受け渡すためのリソースという仕組みがある。これはcore module内では32ビット整数としてあらわされる。今回はC#のオブジェクト参照をwasm側に渡すためにリソースを使用する。
リソースにはRustライクな寿命管理があり、リソースハンドルにはownとborrowの区別がある。ownなハンドルが関数に渡されるとその関数はリソースの所有権を持ち、リソースをが不要になったタイミングでデストラクタを呼ぶ責任を負う。ownなハンドルを別の関数に渡すと、所有権は移る。対してborrowはその関数スコープ内でのみ有効なハンドルであり、線形メモリなどに保持することはできないが、デストラクタを呼ばない。
実装に落とし込むなら、C#のオブジェクトをwasmに渡す際は、そのオブジェクトを何らかのテーブルに登録したうえで、インデックスをownなハンドルとしてwasmに渡す。wasm側で所有権が破棄されればデストラクタが呼ばれるので、テーブルのエントリから外す。するとC#世界の参照も外れるのでGCに回収されるといった具合である。
バインディング生成においては、C# -> wasmに渡るリソースは、wasm側で参照を保持できるようにしたいのでownとして渡す。対してwasm -> C#に渡るリソースはborrowなハンドルとして受け取る。
少し気になっているのは、同じC#オブジェクトが、複数の異なる表現のハンドルとしてwasm側に渡っても問題ないかどうか。つまり、wasm側がハンドルのバイト表現を見てリソースの等価性を判断するとよくない。C#側の実装の都合上、テーブル上でハンドルからオブジェクト参照を辿れるようにするのは簡単だけど、逆はちょっと難しい(できなくはないがパフォーマンスが犠牲になる)ので、ハンドルの重複チェックをやりたくない。同じオブジェクトに別のハンドルを割り当てても大丈夫だとうれしい。
Component Model なランタイムのテスト
バインディングAPIがいい感じにできたので、機能としては一通りそろった。
ところで、Component Modelには公式のテストスイートがない(Component Model のREADMEにはこれから追加するよ!と書かれている。)
ただ、探してみると、wasmtimeとwasm-toolsのリポジトリの中にそれらしいファイルを見つけた。
ということで、こちらをお借りしてみよう。
半年前にコア仕様のテストを行ったが、そのときと同じくwast
フォーマットで書かれている。ただ、中身はComponent Modelなので、以前使ったwabtのwast2json
は使えなかった。
さらに探してみると、wasm-tools
に含まれているwast
クレートにはComponent Modelなwastをパースする実装が既に入っていることがわかった。wasm-tools
はwast
クレートを使ってwast2json
と同等のjson-from-wast
サブコマンドを提供している。しかし、これもComponent Modelなwastには対応していなかった。
パーサー自体はあるので、JSONに変換する実装だけが不足しているということだ。そこでwasm-tools
を適当にフォークして、Component Modelなwastからオレオレ仕様のJSONを吐けるようにしてみた。
これに合わせC#側のテストランナーにもJSONの定義を追加して、実行できるようになった。