WebGL-Native: WASM2Cできるようにする
prev: https://zenn.dev/okuoku/scraps/8bfeea6f22c418
next: https://zenn.dev/okuoku/scraps/80221b8286b8d9
今は実行にNode.JSが必要だが、そのうち無くしたい。まずはWASM部分を wasm2c
でビルドしたネイティブコードに交換できるようにし、その後JavaScript部分を他のインタプリタに移植するのが良いと考えている。
必要なもの
-
モジュールローダー 。
wasm2c
でC言語に変換したWebAssemblyはDLL(いわゆる共有ライブラリ)にビルドされる。このDLLをロードするための機構がJavaScript側に必要になる。今回は 以前 と同様にffi-napi
を引き続き使うことにする。 -
wasm2cコードのためのランタイム 。
wasm2c
が出力するC言語コードは、wasm-rt.h
で宣言されるランタイム関数を必要とする。 - WASM → JS呼び出し機構 。C言語側からJS側を呼んでくるための機構が必要となる。
- JS → WASM呼び出し機構 。こちらは今までのWebGL実装でやってきたのと同じ戦略を流用できる。
wasm2c の出力
wasm2c
コマンドは wabt
https://github.com/WebAssembly/wabt に含まれるコマンドで、WebAssemblyモジュールをほぼ等価なC言語コードに変換する。このC言語コードを gccや互換コンパイラ で処理することにより、WebAssemblyモジュールをAOT(Ahead Of Time)コンパイルできる。
wasm2cの出力は今のところVisualStudioでコンパイルできない。
もっとも、最近のVisualStudioはclangをフロントエンドとして使えるので、そこまで大きな問題ではない。
CMakeプロジェクトであれば、toolsetとして clang_cl_x64
を選ぶとClangでコンパイルされる。
シンボルのマングリング
wasm2c
では、シンボルがマングリングされる。WebAssembly内ではCシンボルとして妥当でない文字もシンボルとして利用できる。
マングリングのアルゴリズムは非常に単純で、
std::string CWriter::MangleName(string_view name) {
const char kPrefix = 'Z';
std::string result = "Z_";
if (!name.empty()) {
for (char c : name) {
if ((isalnum(c) && c != kPrefix) || c == '_') {
result += c;
} else {
result += kPrefix;
result += StringPrintf("%02X", static_cast<uint8_t>(c));
}
}
}
return result;
}
Z
をエスケープ文字とし、 Z_
をヘッダとしている。JavaScript側にシンボルを渡す際は、この逆処理を実施する必要がある。
ヘッダファイル
wasm2c
は、WASMを変換する際にヘッダファイルも一緒に出力する。
/* import: 'env' 'emscripten_set_main_loop_arg' */
extern void (*Z_envZ_emscripten_set_main_loop_argZ_viiii)(u32, u32, u32, u32);
/* export: '__indirect_function_table' */
extern wasm_rt_table_t (*WASM_RT_ADD_PREFIX(Z___indirect_function_table));
/* export: '__wasm_call_ctors' */
extern void (*WASM_RT_ADD_PREFIX(Z___wasm_call_ctorsZ_vv))(void);
今回はこのヘッダファイルを機械的にパースしてWASMモジュールのimport/exportを得ることにした。
方針
他のJavaScript処理系への移植を考えると、可能な限り ffi-napi
への依存は減らしておきたい。このため、全てのC関数を:
void func(uint64_t* in, uint64_t* out);
の形式に変換し、呼び出しプロトコルを変換するstubを適当に生成することにする。これにより、 ffi-napi
のレベルで対応しなければならないC言語関数シグネチャは↑のものに限定できる。
変換アルゴリズム
例えば、
extern u32 (*Z_envZ_emscripten_set_element_css_sizeZ_iidd)(u32, f64, f64);
のようなAPIがあったとして、
// JavaScript → WASM 呼び出しの場合
void
WRAP(uint64_t* in, uint64_t* out){ // JavaScriptからはこれを呼ぶ
u32 arg0 = in[0];
f64 arg1 = *(double *)&in[1];
f64 arg2 = *(double *)&in[2];
u32 retval;
retval = API(arg0, arg1, arg2); // WASMで実装された関数
out[0] = retval;
}
または、
// WASM → JavaScript 呼び出しの場合
u32
Z_envZ_emscripten_set_element_css_sizeZ_iidd(u32 arg0, f64 arg1, f64 arg2){
uint64_t in[3];
uint64_t out[1];
in[0] = arg0;
in[1] = *(uint64_t *)&arg1;
in[2] = *(uint64_t *)&arg2;
WRAP(in, out); // ここでJavaScriptを何とかcallbackする
return out[0];
}
のように変換する。
いわゆるMVP WASMでエクスポートされる関数の型は4種類(void
u32
f32
f64
)しか存在しないため、変換ルーチンもこの4種に対応すれば良いことになる。
※ WASMはlittle-endian専用のため、Big-endianを想定したポインタ補正は必要ない
wasm2c出力ヘッダからimport/exportシンボル情報の抽出
CMakeを使ってパースしてみた。wasm2c
で出力したヘッダファイルをこのスクリプトに通すと、
/* import: 'env' 'glViewport' */
extern void (*Z_envZ_glViewportZ_viiii)(u32, u32, u32, u32);
のような宣言が、
set(sym_Z_envZ_glViewportZ_viiii_symtype IMPORT_FUNCTION)
set(sym_Z_envZ_glViewportZ_viiii_datatype void)
set(sym_Z_envZ_glViewportZ_viiii_argcount 4)
set(sym_Z_envZ_glViewportZ_viiii_argtype0 u32)
set(sym_Z_envZ_glViewportZ_viiii_argtype1 u32)
set(sym_Z_envZ_glViewportZ_viiii_argtype2 u32)
set(sym_Z_envZ_glViewportZ_viiii_argtype3 u32)
というCMakeスクリプトに変換される。これを実際のDLLをビルドするCMakeプロジェクト内で処理して、stubソースコードの生成を行うことになる。
クロージャ呼び出し vs. コンテキスト呼び出し
JavaScript → C の呼び出しは、C側の関数ポインタを渡すことで可能になる。このため、JavaScript側には関数ポインタを直接渡せば良い。
逆に、 C → JavaScript 呼び出しでは、3通りの戦略が考えられる。
-
クロージャ呼び出し (動的トランポリン)。JavaScript側で有効なC関数ポインタを 動的に 生成し、C側に渡して呼んでもらう。有効な関数ポインタを生成するためには、Executable memoryへの書き込みが必要になり、iOSやゲーム機ではこちらの戦略は取れない(動的に.soを生成するみたいな裏技は別として)。
ffi
ライブラリではこちらに対応している。 -
コンテキスト呼び出し (静的トランポリン、JavaScript側) 。JavaScript側で "どの
function
が呼ばれるべきか" を表わす情報をC側に渡し、 JavaScript側の入口を1つに絞る 。C側の呼び出しプロトコルを変更する必要がある。 -
トランポリン呼び出し (静的トランポリン、C側) 。事前にトランポリンになるC側の関数を大量に並べておき、JavaScript側で
function
を確保するたびにトランポリンを予約して飛び先や付加情報を登録できるようにする。事前に生成したトランポリンの数が呼び出し先の個数制限になるため、動的にfunctionを生成できるJavaScriptでは不向きと言える。
ffi-napi
ライブラリはクロージャ呼び出しに必要な機能を備えているものの、Node.JS以外に移植する際の面倒になる可能性があるため今回はコンテキスト呼び出しを採用する。
ランタイムライブラリの設計
wasm2c
で生成したCコードから呼ばれるメモリ確保APIや、JavaScript側からデータのimport/exportを行うための処理を行うAPIを用意する必要がある。
全てのAPIは void func(uint64_t* in, uint64_t* out)
のシグネチャを持ち、一部は多値を返却する。
FIXME: 今回はWASMのfunction typeをサポートしない ★。
get_callback
0:0 [0 0 idx] => [call]
(将来拡張用。admライブラリの関数を返す。)
set_callback
0:1 [0 1 addr] => []
ライブラリ番号ゼロ番はモジュールシステムの管理用に使用する。
C → JavaScript 呼び出しに使用する呼び出しAPIの関数ポインタを設定する。
bootstrap ライブラリ
これらは↑とは逆にJavaScript側で実装し、WASM側から呼ばれる。
wasm_boot_allocate_memory
bootstrap:1 [1 instance_id initial_pages max_pages] => [native_addr current_pages]
WASMモジュールが使用するメモリサイズを宣言する。現状のWASM MVP仕様では、メモリは高々1つしか持てない。
JavaScript側と共有するプロセスのメモリアドレスを返却する。
wasm_boot_allocate_table
bootstrap:2 [2 instance_id elements max_elements] => []
WASMモジュールが使用する table
を宣言する。現状のWASM使用では、tableはimmutableと見做せるため、後述の wasm_boot_read_table
でデータを読み取ってJavaScript側にコピーすることになる。
wasm_boot_grow_memory
bootstrap:3 [3 instance_id pages] => [result current_pages]
memory
を pages
ぶんだけ拡張する。失敗した場合は非ゼロを返却する。(WASM2Cは失敗時に UINT32_MAX
の返却を求めている。)
wasm_register_func_type
bootstrap:4 [4 params results paramtype ... resulttype ...] => [typeidx]
WASM generic
(ABI情報無しだと厳しそうなのでちょっと仕切りなおし。Callinfoを追加。)
↓ 移動
adm ライブラリ
ネイティブコード側から呼ばれることを想定したネイティブコード。
short_circuit
adm:1 short_circuitは特殊なAPIで、
[funcptr arg ...] => [res ...]
のような形式の呼び出しを、 funcptr
をC言語関数ポインタと見做して実施するもの。Node.JSでは、callbackのために関数ポインタを生成することができるので専用の呼び出し機構は不要なため、 set_callback
に short_circuit
の関数ポインタを渡しておき、 wasm_set_bootstrap
に渡すコンテキストとして関数ポインタを使うことになる。
shufflecall_ptr
adm:2 shufflecallは、JavaScript的なbufferをGCから保護しつつ呼び出しを実施するもので、通常とは異なる呼び出し規約を持つ。
C呼び出しシグネチャは:
void shufflecall_ptr(uint64_t* cmd, uint64_t* ret,uint64_t cmdoffset,
void* p0, void* p1, void* p2, void* p3);
このAPIによって、JavaScript側のバッファの実アドレスで引数をパッチしながらAPIを連鎖呼び出しできる。一度に4つまでしかポインタをセットできないため、追加のポインタをセットする必要があればcallbackを使う必要がある。
shufflecallは処理系が安全なCコールバックを持っている場合に一般に使用できるテクニックだが、バッファに対するローレベルアクセスを提供している場合は不要となる。
代入オフセットに -1
を設定すると、以降の 引数は無視される。(ポインタの引数に NULL
を設定する形にした場合、 cmd
にNULLを設定する方法がなくなってしまうため)
★ コマンドを破壊するように使うことは無いから、代入オフセット 0 付近も使えるな。。
offset | コマンド |
---|---|
+0 | 呼び出しコンテキストのタイプ(0 = C関数ポインタ、1 = set_callback ) |
+1 | 連鎖呼び出しする呼び出しコンテキスト(C関数ポインタ 0 の場合は呼び出しをしない) |
+2 | 呼び出し時の cmd オフセット(set_callback の場合このオフセットにコンテキストが代入される) |
+3 | 呼び出し時の ret オフセット |
+4 | p0の代入オフセット(cmd 相対、負値不可) |
+5 | p0に加えるオフセット(負値可) |
+6 | p1の代入オフセット |
+7 | p1に加えるオフセット |
+8 | p2の代入オフセット |
+9 | p2に加えるオフセット |
+10 | p3の代入オフセット |
+11 | p3に加えるオフセット |
shufflecall_float
adm:3 float版。(node-ffiでは本来不要)
void shufflecall_float(uint64_t* cmd, uint64_t* ret, float f0, float f1, float f2, float f3);
shufflecall_double
adm:4 double版。(node-ffiでは本来不要)
void shufflecall_double(uint64_t* cmd, uint64_t* ret, double d0, double d1, double d2, double d3);
まったく動作確認してないけどとりあえずpushした。長くなっちゃったので分ける。