Open9

WebGL-Native: WASM2Cできるようにする

okuokuokuoku

prev: https://zenn.dev/okuoku/scraps/8bfeea6f22c418
next: https://zenn.dev/okuoku/scraps/80221b8286b8d9

今は実行にNode.JSが必要だが、そのうち無くしたい。まずはWASM部分を wasm2c でビルドしたネイティブコードに交換できるようにし、その後JavaScript部分を他のインタプリタに移植するのが良いと考えている。

必要なもの

  1. モジュールローダーwasm2c でC言語に変換したWebAssemblyはDLL(いわゆる共有ライブラリ)にビルドされる。このDLLをロードするための機構がJavaScript側に必要になる。今回は 以前 と同様に ffi-napiを引き続き使うことにする。
  2. wasm2cコードのためのランタイムwasm2c が出力するC言語コードは、 wasm-rt.h で宣言されるランタイム関数を必要とする。
  3. WASM → JS呼び出し機構 。C言語側からJS側を呼んでくるための機構が必要となる。
  4. JS → WASM呼び出し機構 。こちらは今までのWebGL実装でやってきたのと同じ戦略を流用できる。
okuokuokuoku

wasm2c の出力

wasm2c コマンドは wabt https://github.com/WebAssembly/wabt に含まれるコマンドで、WebAssemblyモジュールをほぼ等価なC言語コードに変換する。このC言語コードを gccや互換コンパイラ で処理することにより、WebAssemblyモジュールをAOT(Ahead Of Time)コンパイルできる。

wasm2cの出力は今のところVisualStudioでコンパイルできない。

https://github.com/WebAssembly/wabt/issues/1366

もっとも、最近の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を得ることにした。

okuokuokuoku

方針

他の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を想定したポインタ補正は必要ない

okuokuokuoku

wasm2c出力ヘッダからimport/exportシンボル情報の抽出

https://github.com/okuoku/cwgl-proto/commit/12f52e4c57f168c57938b0dc97650588734a5124

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ソースコードの生成を行うことになる。

okuokuokuoku

クロージャ呼び出し vs. コンテキスト呼び出し

JavaScript → C の呼び出しは、C側の関数ポインタを渡すことで可能になる。このため、JavaScript側には関数ポインタを直接渡せば良い。

逆に、 C → JavaScript 呼び出しでは、3通りの戦略が考えられる。

  1. クロージャ呼び出し (動的トランポリン)。JavaScript側で有効なC関数ポインタを 動的に 生成し、C側に渡して呼んでもらう。有効な関数ポインタを生成するためには、Executable memoryへの書き込みが必要になり、iOSやゲーム機ではこちらの戦略は取れない(動的に.soを生成するみたいな裏技は別として)。 ffi ライブラリではこちらに対応している。
  2. コンテキスト呼び出し (静的トランポリン、JavaScript側) 。JavaScript側で "どの function が呼ばれるべきか" を表わす情報をC側に渡し、 JavaScript側の入口を1つに絞る 。C側の呼び出しプロトコルを変更する必要がある。
  3. トランポリン呼び出し (静的トランポリン、C側) 。事前にトランポリンになるC側の関数を大量に並べておき、JavaScript側で function を確保するたびにトランポリンを予約して飛び先や付加情報を登録できるようにする。事前に生成したトランポリンの数が呼び出し先の個数制限になるため、動的にfunctionを生成できるJavaScriptでは不向きと言える。

ffi-napi ライブラリはクロージャ呼び出しに必要な機能を備えているものの、Node.JS以外に移植する際の面倒になる可能性があるため今回はコンテキスト呼び出しを採用する。

okuokuokuoku

ランタイムライブラリの設計

wasm2c で生成したCコードから呼ばれるメモリ確保APIや、JavaScript側からデータのimport/exportを行うための処理を行うAPIを用意する必要がある。

全てのAPIは void func(uint64_t* in, uint64_t* out) のシグネチャを持ち、一部は多値を返却する。

FIXME: 今回はWASMのfunction typeをサポートしない ★。

0:0 get_callback

[0 0 idx] => [call]

(将来拡張用。admライブラリの関数を返す。)

0:1 set_callback

[0 1 addr] => []

ライブラリ番号ゼロ番はモジュールシステムの管理用に使用する。

C → JavaScript 呼び出しに使用する呼び出しAPIの関数ポインタを設定する。

bootstrap ライブラリ

これらは↑とは逆にJavaScript側で実装し、WASM側から呼ばれる。

bootstrap:1 wasm_boot_allocate_memory

[1 instance_id initial_pages max_pages] => [native_addr current_pages]

WASMモジュールが使用するメモリサイズを宣言する。現状のWASM MVP仕様では、メモリは高々1つしか持てない。

JavaScript側と共有するプロセスのメモリアドレスを返却する。

bootstrap:2 wasm_boot_allocate_table

[2 instance_id elements max_elements] => []

WASMモジュールが使用する table を宣言する。現状のWASM使用では、tableはimmutableと見做せるため、後述の wasm_boot_read_table でデータを読み取ってJavaScript側にコピーすることになる。

bootstrap:3 wasm_boot_grow_memory

[3 instance_id pages] => [result current_pages]

memorypages ぶんだけ拡張する。失敗した場合は非ゼロを返却する。(WASM2Cは失敗時に UINT32_MAX の返却を求めている。)

bootstrap:4 wasm_register_func_type

[4 params results paramtype ... resulttype ...] => [typeidx]
okuokuokuoku

adm ライブラリ

ネイティブコード側から呼ばれることを想定したネイティブコード。

adm:1 short_circuit

short_circuitは特殊なAPIで、

[funcptr arg ...] => [res ...]

のような形式の呼び出しを、 funcptr をC言語関数ポインタと見做して実施するもの。Node.JSでは、callbackのために関数ポインタを生成することができるので専用の呼び出し機構は不要なため、 set_callbackshort_circuit の関数ポインタを渡しておき、 wasm_set_bootstrap に渡すコンテキストとして関数ポインタを使うことになる。

adm:2 shufflecall_ptr

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に加えるオフセット

adm:3 shufflecall_float

float版。(node-ffiでは本来不要)

void shufflecall_float(uint64_t* cmd, uint64_t* ret, float f0, float f1, float f2, float f3);

adm:4 shufflecall_double

double版。(node-ffiでは本来不要)

void shufflecall_double(uint64_t* cmd, uint64_t* ret, double d0, double d1, double d2, double d3);