Open19

Unity/.NETでWebAssembly式スクリプトエンジンを作る

rucchoruccho

Unityで動くWebAssemblyランタイムを作ると、スクリプトエンジン(会話とか用)として使いやすいんじゃないかというアイデア。今回のスコープではないけど、UGC用途もわりとアリかも。

先行実装

https://github.com/RyanLamansky/dotnet-webassembly

  • dotnet-webassemblyという、C#で書かれたWebAssemblyランタイムがあったので見てみたけど、wasm -> IL -> JITという感じだった。速度出そうだしなかなか楽しそうなアプローチだ
  • 今回は後述の通り、AOTセーフで行くのでこれはパス

https://github.com/jonathanvdc/cs-wasm

  • インタプリタつき。よさそうだけど更新されてない

wasmにコンパイルできるいろんな言語が使える

  • https://github.com/appcypher/awesome-wasm-langs
  • わりとみんなRustでwasm書いてるけど、インスタントなスクリプト向きではないかも
  • emscriptenとかbinaryenとかもあるので、自作言語書く下地が十分にあるのもよい

AOTセーフにする

  • 今回はIL2CPPセーフにしておきたいので、動的コード生成はナシでインタプリタ式で行く
  • 実行速度は出ないだろうけど、今回の用途で困ることはないでしょう

非同期処理を書けるようにする

  • wasmで非同期を書きたい。
  • wasm側からランタイムのAPIを同期的に呼ぶと、そこでランタイムが中断して、C#側のタスク完了を待つ、みたいな仕組みをつくれそう

バインディング

  • C#/Unityとの連携のさせ方をどうするか
rucchoruccho

wasmとの相互運用について

WASI (WebAssembly System Interface)

一般的なシステムコールをwasmから呼び出すためのAPIセット。実装しといてもいいかもしれない

WebAssembly Component Model

https://github.com/WebAssembly/component-model
https://zenn.dev/newgyu/articles/82a181212c7bb2
wasmモジュール(コンポーネント)間やランタイムと相互運用する際のABIなどを定めたもの。
IDL (WIT) で関数やデータ構造を定義して、各言語向けのヘッダファイルを生成したりするツールチェインを利用する。ただ現状はまだ対応言語少なめ(Rust/C/C++/Java/Go)。

rucchoruccho

とりあえず純粋なwasmランタイムを実装する

コア仕様にはv1.0と、v2.0のドラフトがあり、v2.0では命令もいろいろ増えている。
ひとまずv1.0だけ実装して動くところまでもっていく
https://www.w3.org/TR/wasm-core-1/

rucchoruccho

スタックサイズについて

公式テストスイートの中に、スタックを溢れさせるテストがあった。
今回は非同期対応を視野に入れているため、中断ポイントでスタックの内容を保存しなければならず、必然的にスレッド自体のスタックは使わず、別途ヒープに確保した領域をwasmスタックとして使っている。
一般的なOSスレッドのスタックサイズは1MBとか2MBとかだけど、今回のようにヒープを使っていると、ほとんど無尽蔵にスタック領域を確保できてしまう。

どこかに限界を定めるべきだろうけど、どのくらいにしたものか……。

そもそもスタックはインスタンス化時点で決まったサイズを確保したところで、ほとんどのケースではそのうちごく少量しか使わないであろうから、必要に応じて伸長する方式のほうが望ましい。
wasmの関数は、ある命令の時点におけるスタックの深さ(と値の型)が一意に定まることになっているから、関数の実行に必要なスタックの大きさは事前に計算することができる。また、関数を横断してスタックの値を参照することはないから、スタックは必ずしも連続している必要はない。関数=フレームごとに細切れの(2の累乗サイズとかの)スタックを確保し、それらを連結リストで保持するのがよいかもしれない。

rucchoruccho

f32.convert_i64_uのテストがコケる。テストケースだと9.0072E+15だけど、C#的に素直に実装すると9.007199E+15が返ってくる……。これは浮動小数点数の丸め方が違うのか

rucchoruccho

9.0072E+150x5A000000で、
9.007199E+150x5A000001

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

https://github.com/dotnet/runtime/issues/27174
https://learn.microsoft.com/ja-jp/dotnet/api/system.midpointrounding
これだけ見ると、wasmと今の.NETで丸め挙動に差があるようには見えないけど……?

rucchoruccho

なんか、浮動小数点数 -> 整数 変換時の丸め挙動についてはいろいろ情報があるけど、逆についてはほとんどわからん

rucchoruccho

まだテスト通してる途中だけど、バインディングについて考えてること

バインディング層は抽象化する

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が現れて、シグネチャが静的に決定したバージョンのオーバーロードを生やしてくれる。あとはそこでよしなにマーシャリングすればよい。

rucchoruccho

やっとwasm core v1.0テストが全部通った……

感想

  • バリデーション、特にスタック状態のバリデーションがとにかく面倒だった
    • ブロックによる制御フローは考慮事項が多くて大変
    • unreachable命令が絡む検証はムズい
  • 浮動小数点数が絡む計算はC#的に素直な書き方とwasmで細かい仕様が異なるものが多かった
    • 一部は厳密なチェックを諦めました(つまりぜんぜん全部パスではない!)
  • エラーメッセージの検証は面倒かつ本質的ではないのでスキップしました
    • とりあえず有効なモジュールとそうでないモジュールが区別できればOKということに……
    • 余裕出てきたらここもやる
  • 先人の記事にいろいろなハマりポイントが書いてありとても助かりました

ということで、wasm core v1.0が動く純粋なwasmインタプリタができたので一段落。

このあとは、

  • いくらか実際に自分で書いたコードをwasmにコンパイルして動かしてみて遊ぶ
  • いったんコードの整理とリファクタリングする
  • バインディングの実装に入る
rucchoruccho

Component Model と Canonical ABI

Binding APIの設計がわからなくなってきたので、先にComponent ModelとCanonical ABIななめ読み

https://github.com/WebAssembly/component-model/blob/main/design/mvp/Explainer.md#canonical-definitions

lift と lower

component modelでは、core仕様準拠のモジュールの外側に様々メタデータを付与するバイナリフォーマットを用いている。そのメタデータの中では、コンポーネントでの関数の呼び出しに際し、コア仕様の基本的なデータ表現と、Component Modelでのリッチなデータ表現を交換するためのアノテーションliftlowerが記述される。

  • lift: core関数をラップして、Componentレベルの関数定義を作成する
  • lower:Component関数をラップして、モジュール内部のコードから呼び出せる関数定義を作成する
rucchoruccho

具体的なliftとlowerの方法はここ
https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#lifting-and-lowering-values

flattening

flatという文字が見えるけど、おそらくパラメータ数がある程度まではwasm上もパラメータとして渡すが、パラメータが多い場合はいったん線形メモリに展開してポインタを渡すということだろう

https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#flattening

パラメータの場合、フラット化の閾値は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をカバーできるようにしている

rucchoruccho
  • Binding API
  • C# <-> wasmのBoxing回避のためのSource Generatorの実装
  • コルーチン

このへん実装ができたので、基本機能としてはだいぶそろってきた
あとはComponent Modelまわりの実装して公開かな……

rucchoruccho

マネージドオブジェクトの相互運用

UnityのAPIのバインディングをつくるなら、wasm側にC#オブジェクト(のハンドル)を渡せるようにしたいところ
.NETのマネージドオブジェクトはGCで移動されうるので、ポインタをwasmワールドに直に渡すわけにはいかないし、wasmワールドからの参照をトラックしないといけないので、マネージドオブジェクトの受け渡しにはなにかしらのハンドルを介する必要がある

  • Reference Types (externref)
  • Component Model
    • コンポーネント間で受け渡せる値の種類に「ハンドル」があり、コンポーネント側で不要になったタイミングでdrop処理が呼ばれる
    • 参照カウンタ機構を作って、drop時にカウンタを減らす
    • 実用上wit-bindgenが必要で、まだ対応言語が少ない
    • https://github.com/bytecodealliance/wit-bindgen
  • UnityEngine.ObjectのInstance ID
    • UnityEngine.Object限定ならinstance idで参照トラックできなくもない
  • ランタイム側でハンドルを追跡する
    • wasmワールドに渡されたハンドルの行方を追跡し、値が変更されたり破棄された時点で参照が切れたとみなす
    • 設計次第だが、線形メモリ上の値にラベルを付ける必要があるので、メモリ操作全体にわたってオーバーヘッドが生じるかもしれない
    • ポインタに対する演算はないものと仮定する必要があるが、本当にそれは成り立つのか
rucchoruccho

上記の中では、(普及さえすれば)Component Modelが最良と思えるので、Component Modelで実装を進める。当面はRustくらいしか現実的な選択肢にならないかもしれないが、今後流行ることを祈って……

ということで、Component Modelの仕様をちゃんと読み始めた

https://zenn.dev/ruccho/scraps/f534e0e44d6a38

バインディングはUnityCsReferenceなどからRoslynでAPI情報を抜き出してWITのインターフェース定義を生成し、それをもとにユーザーがコンポーネントを書くイメージ。

マネージドオブジェクトはC#側に参照カウンタテーブルをつくっておき、WASMからの参照が切れたらC#側も参照をはずす。

rucchoruccho

Compoennt Modelバイナリのパーサー実装がそれなりに進んで、cargo-componentで出したhello-worldなcomponentが全部読めるようになった

次はランタイム実装、とはいってもComponent ModelはCore Moduleにリッチな型のメタデータをかぶせたフォーマットにすぎないので、コンポーネント・モジュール境界でのデータ交換さえ実装すれば、それ以外にランタイムに手を加える必要は(ほぼ)ないはず

最後の問題はバインディングをどうするかで、Core Moduleのデータ型はたいして種類がなかったのでSpan<WasmValueType>みたいなものでフラットに受け渡しができたが、Component Modelではそうはいかない。Component ModelにはWITがあるので、基本的にはWITから事前生成したバインディング実装を通して呼び出すのがパフォーマンス的にも開発体験的にも最良な気がする
でもWITのパーサー書くのめんどいな……

rucchoruccho

C# <-> wasm component境界の引数・戻り値の交換を行う仕組みはできた。あとはC#・wasm componentの双方から見て最も自然な形でFFIできるようにするためのバインディング生成部分をやっていく

ユースケース

WASI preview 2

例えばRustのwasm32-wasip1wasm32-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ライクな寿命管理があり、リソースハンドルにはownborrowの区別がある。ownなハンドルが関数に渡されるとその関数はリソースの所有権を持ち、リソースをが不要になったタイミングでデストラクタを呼ぶ責任を負う。ownなハンドルを別の関数に渡すと、所有権は移る。対してborrowはその関数スコープ内でのみ有効なハンドルであり、線形メモリなどに保持することはできないが、デストラクタを呼ばない。

実装に落とし込むなら、C#のオブジェクトをwasmに渡す際は、そのオブジェクトを何らかのテーブルに登録したうえで、インデックスをownなハンドルとしてwasmに渡す。wasm側で所有権が破棄されればデストラクタが呼ばれるので、テーブルのエントリから外す。するとC#世界の参照も外れるのでGCに回収されるといった具合である。

バインディング生成においては、C# -> wasmに渡るリソースは、wasm側で参照を保持できるようにしたいのでownとして渡す。対してwasm -> C#に渡るリソースはborrowなハンドルとして受け取る。

少し気になっているのは、同じC#オブジェクトが、複数の異なる表現のハンドルとしてwasm側に渡っても問題ないかどうか。つまり、wasm側がハンドルのバイト表現を見てリソースの等価性を判断するとよくない。C#側の実装の都合上、テーブル上でハンドルからオブジェクト参照を辿れるようにするのは簡単だけど、逆はちょっと難しい(できなくはないがパフォーマンスが犠牲になる)ので、ハンドルの重複チェックをやりたくない。同じオブジェクトに別のハンドルを割り当てても大丈夫だとうれしい。

rucchoruccho

Component Model なランタイムのテスト

バインディングAPIがいい感じにできたので、機能としては一通りそろった。
ところで、Component Modelには公式のテストスイートがない(Component Model のREADMEにはこれから追加するよ!と書かれている。)

ただ、探してみると、wasmtimeとwasm-toolsのリポジトリの中にそれらしいファイルを見つけた。

https://github.com/bytecodealliance/wasmtime/tree/main/tests/misc_testsuite/component-model

ということで、こちらをお借りしてみよう。
半年前にコア仕様のテストを行ったが、そのときと同じくwastフォーマットで書かれている。ただ、中身はComponent Modelなので、以前使ったwabtのwast2json は使えなかった。

さらに探してみると、wasm-toolsに含まれているwastクレートにはComponent Modelなwastをパースする実装が既に入っていることがわかった。wasm-toolswastクレートを使ってwast2jsonと同等のjson-from-wastサブコマンドを提供している。しかし、これもComponent Modelなwastには対応していなかった。

https://github.com/bytecodealliance/wasm-tools/blob/main/src/bin/wasm-tools/json_from_wast.rs#L365

パーサー自体はあるので、JSONに変換する実装だけが不足しているということだ。そこでwasm-toolsを適当にフォークして、Component Modelなwastからオレオレ仕様のJSONを吐けるようにしてみた。

https://github.com/ruccho/wasm-tools/blob/main/src/bin/wasm-tools/json_from_wast.rs

これに合わせC#側のテストランナーにもJSONの定義を追加して、実行できるようになった。