rustc/wasm-bindgenの生成するWebAssemblyを観察する
WebAssemblyの言語としての仕様を理解するため、Rustのコードがrustc/wasm-bindgenによってWebAssemblyにどのようにコンパイルされるかを延々と試したメモ。Mapping High Level Constructs to LLVM IRを意識している。
ビルドにはwasm-packを用いているため、出力はnpmパッケージの形になる。また .wasm
ファイルはwasm-optによる最適化がかかった形となる。
空のモジュール
// nothing
(module
(memory $memory 16)
(export "memory" (memory 0)))
memory
は線形メモリの定義。JSのArrayBufferに対応する。export/importは後で追う。
Wasm memory is an expandable array of bytes that Javascript and Wasm can synchronously read and modify. Linear memory can be used for many things, one of them being passing values back and forth between Wasm and Javascript.
関数(1): i32 + i32
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn foo(a: i32, b: i32) -> i32 {
a + b
}
(module
(type $t0 (func (param i32 i32) (result i32)))
(func $foo (type $t0) (param $p0 i32) (param $p1 i32) (result i32)
local.get $p0
local.get $p1
i32.add)
(memory $memory 17)
(export "memory" (memory 0))
(export "foo" (func $foo))
(data $d0 (i32.const 1048576) "\04"))
import * as wasm from './index_bg.wasm';
...
export function foo(a, b) {
var ret = wasm.foo(a, b);
return ret;
}
...
export function foo(a: number, b: number): number;
まず関数の型がtypeによって定義される。モジュール内で使われる関数の型はこのように定義される必要がある。そしてfuncによって関数が定義される。関数はパラメータ(param)を持てて、結果(result)を持てる (現在の仕様では高々1つまで [1])。
定義はゼロベースのインデックスによって参照されるが、Text Formatでは$から始まる識別子を利用できる。 $t0
や $foo
はいずれも実際にはu32のインデックス。
WebAssemblyの計算モデルはスタックマシンをベースとしており、レジスタ $p0
$p1
から値を取得 (してスタックにpush)、加算 (スタックからオペランドをpopして加算結果をpush) している。
-
どこかで読んだがSpecから出典を発見できず、Spec的にはこの制限は無いかもしれない ↩︎
関数(2): (i64, i64) -> i32
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn foo(a: i64, b: i64) -> i32 {
a as i32 + b as i32
}
(module
(type $t0 (func (param i32 i32 i32 i32) (result i32)))
(func $foo (type $t0) (param $p0 i32) (param $p1 i32) (param $p2 i32) (param $p3 i32) (result i32)
local.get $p0
local.get $p2
i32.add)
(memory $memory 17)
(export "memory" (memory 0))
(export "foo" (func $foo))
(data $d0 (i32.const 1048576) "\04"))
import * as wasm from './index_bg.wasm';
const u32CvtShim = new Uint32Array(2);
const int64CvtShim = new BigInt64Array(u32CvtShim.buffer);
...
export function foo(a, b) {
int64CvtShim[0] = a;
const low0 = u32CvtShim[0];
const high0 = u32CvtShim[1];
int64CvtShim[0] = b;
const low1 = u32CvtShim[0];
const high1 = u32CvtShim[1];
var ret = wasm.foo(low0, high0, low1, high1);
return ret;
}
export function foo(a: BigInt, b: BigInt): number;
2引数の関数 fn foo
がWebAssembly上では4引数となっている。グルーコードを見ると、それぞれ32bitの整数2つを用いて関数を呼び出すようになっている。WebAssembly自体は64bit整数(i64)をサポートしているが、JavaScriptのnumberは64bit整数を表現するのに十分ではないためこうする必要があるのだろう。
wasmへのコンパイル時、このようなシグネチャの関数を提供できているのはwasm-bindgenの仕事による。rustcが生成する target/
の最適化前のwasmでは型 (type $t8 (func (param i64 i64) (result i32)))
を持つ関数 $_ZN12wasm_sandbox3foo17hd595de168824fe9dE
が確認できる。JSから呼び出される $foo
は、 (param $p0 i32) (param $p1 i32) (param $p2 i32) (param $p3 i32)
から本来の引数への変換を行って $_ZN12wasm_sandbox3foo17hd595de168824fe9dE
を呼び出すラッパー関数となっている (この過程は wasm-opt
の最適化によって消えている)。
関数(3): i32 -> i64
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn foo(a: i32) -> i64 {
a as i64 * 10
}
(module
(type $t0 (func (param i32 i32)))
(type $t1 (func (param i32) (result i32)))
(func $foo (type $t0) (param $p0 i32) (param $p1 i32)
(local $l2 i64)
local.get $p0
local.get $p1
i64.extend_i32_s
i64.const 10
i64.mul
local.tee $l2
i64.store32
local.get $p0
local.get $l2
i64.const 32
i64.shr_u
i64.store32 offset=4)
(func $__wbindgen_add_to_stack_pointer (type $t1) (param $p0 i32) (result i32)
local.get $p0
global.get $g0
i32.add
global.set $g0
global.get $g0)
(memory $memory 17)
(global $g0 (mut i32) (i32.const 1048576))
(export "memory" (memory 0))
(export "foo" (func $foo))
(export "__wbindgen_add_to_stack_pointer" (func $__wbindgen_add_to_stack_pointer))
(data $d0 (i32.const 1048576) "\04"))
import * as wasm from './index_bg.wasm';
function getInt32Memory0() {
return new Int32Array(wasm.memory.buffer); // 実際にはキャッシュされてる
}
const u32CvtShim = new Uint32Array(2);
const int64CvtShim = new BigInt64Array(u32CvtShim.buffer);
...
export function foo(a) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
wasm.foo(retptr, a);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
u32CvtShim[0] = r0;
u32CvtShim[1] = r1;
const n0 = int64CvtShim[0];
return n0;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
export function foo(a: number): BigInt;
JSのグルーコードを見ると返値がポインタ (ここではwasmの線形メモリ上のインデックス) 経由となっている。この領域の確保に $__wbindgen_add_to_stack_pointer
なる関数を呼び出している。
$__wbindgen_add_to_stack_pointer
はグローバル $g0
に引数の値を加えている。globalによってグローバル変数を定義できる。線形メモリの定義 (memory $memory 17)
の大きさはページサイズ単位であり、約1.06MiBあり、そのうち先頭1MiB (= 1048576)をスタック領域に充てていることがわかる。
$foo
。 (local $l2 i64)
はローカル変数の定義。パラメータと同じく仮想的なレジスタとして使用できる [1]。関数本体は $p1
をi64に符号付き拡張してconst 10をかけて (local $l2 i64)
に持ち、下位32bitと上位32bitを $p0
(retptr) にストアしている。 i64.store32 offset=4
のような相対位置を取れる命令も見られる。
-
インデックスとしてはパラメータに次ぐインデックスが振られる ↩︎
export
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
fn hello_import(x: i32, y: i32) -> i32;
}
#[wasm_bindgen]
pub fn foo(a: i32) -> i32 {
hello_import(a, a)
}
(module
(type $t0 (func (param i32) (result i32)))
(type $t1 (func (param i32 i32) (result i32)))
(import "./index_bg.js" "__wbg_helloimport_08fc05e269b19bdc" (func $./index_bg.js.__wbg_helloimport_08fc05e269b19bdc (type $t1)))
(func $foo (type $t0) (param $p0 i32) (result i32)
local.get $p0
local.get $p0
call $./index_bg.js.__wbg_helloimport_08fc05e269b19bdc)
(memory $memory 17)
(export "memory" (memory 0))
(export "foo" (func $foo))
(data $d0 (i32.const 1048576) "\04"))
import * as wasm from './index_bg.wasm';
function notDefined(what) { return () => { throw new Error(`${what} is not defined`); }; }
...
export function foo(a) {
var ret = wasm.foo(a);
return ret;
}
export const __wbg_helloimport_08fc05e269b19bdc = typeof hello_import == 'function' ? hello_import : notDefined('hello_import');
Rustのexternはimport定義となる。順に、モジュール、名前、定義となっている。JSのグルーコード側で hello_import
との対応付けがなされている
未調査 (今回の本筋から逸れるため)
- Webpackはどのように
WebAssembly.instantiate
しているか- importを解決してinstantiateに与えるところまでも肩代わりしてるはず
- wasm-bindgenのimport関連のオプション
- wasm-packによって生成されるパッケージが別のパッケージに依存したい場合はどうする?
wasm-opt, wabtが最適化パスも提供しているのは違和感があるなと調べたらwabtではなくbinaryenによって提供されているツールだった。GrainやAssemblyScriptはbinaryenを用いてWebAssemblyへのコンパイルを行っているとのこと。binaryen.jsのhello-world.jsなどを見るとかなり手軽で、このあたり色々活用方法がありそうだなあとか
enum
#[wasm_bindgen]
がサポートするのはC-likeなenumのみの様子。
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub enum Foo {
A,
B,
C,
}
#[wasm_bindgen]
pub fn foo(a: Foo) -> i32 {
match a {
Foo::A => 10,
Foo::B => 13,
Foo::C => 20,
}
}
(module
(type $t0 (func (param i32 i32)))
(type $t1 (func (param i32) (result i32)))
(import "./index_bg.js" "__wbindgen_throw" (func $./index_bg.js.__wbindgen_throw (type $t0)))
(func $foo (type $t1) (param $p0 i32) (result i32)
local.get $p0
i32.const 3
i32.ge_u
if $I0
i32.const 1048576
i32.const 25
call $./index_bg.js.__wbindgen_throw
unreachable
end
local.get $p0
i32.const 2
i32.shl
i32.const 1048604
i32.add
i32.load)
(memory $memory 17)
(export "memory" (memory 0))
(export "foo" (func $foo))
(data $d0 (i32.const 1048576) "invalid enum value passed\00\00\00\0a\00\00\00\0d\00\00\00\14\00\00\00\04"))
import * as wasm from './index_bg.wasm';
const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;
let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
function getUint8Memory0() { ... }
function getStringFromWasm0(ptr, len) {
return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));
}
...
export function foo(a) {
var ret = wasm.foo(a);
return ret;
}
export const Foo = Object.freeze({ A:0,"0":"A",B:1,"1":"B",C:2,"2":"C", });
export function __wbindgen_throw(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};
...
export function foo(a: number): number;
...
export enum Foo {
A,
B,
C,
}
enumの値域外のassertionのために __wbindgen_throw
が加わっている。値域外のデータが渡ってきた場合は __wbindgen_throw
を呼ぶ。 25
はエラーメッセージ "invalid enum value passed"
の文字列長。dataセグメントによって線形メモリの初期値を設定している。
式 A => 10, B => 13, C => 20
の変換にも線形メモリが用いられている。最適化がかかっているが 1048604 + 28 + 4 * $p0
を見ると確かにリトルエンディアンで32bit整数 0xA
0xD
0x14
が含まれている。最適化の結果かと思ったが、 cargo build
の出力時点でdataセグメントは統合されていた。
struct
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Foo {
pub x: i32,
pub y: i32,
}
#[wasm_bindgen]
impl Foo {
pub fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
pub fn sqr_distance(&self) -> i32 {
self.x * self.x + self.y * self.y
}
}
import * as wasm from './index_bg.wasm';
export class Foo {
static __wrap(ptr) {
const obj = Object.create(Foo.prototype);
obj.ptr = ptr;
return obj;
}
__destroy_into_raw() {
const ptr = this.ptr;
this.ptr = 0;
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_foo_free(ptr);
}
get x() {
var ret = wasm.__wbg_get_foo_x(this.ptr);
return ret;
}
set x(arg0) {
wasm.__wbg_set_foo_x(this.ptr, arg0);
}
get y() {
var ret = wasm.__wbg_get_foo_y(this.ptr);
return ret;
}
set y(arg0) {
wasm.__wbg_set_foo_y(this.ptr, arg0);
}
static new(x, y) {
var ret = wasm.foo_new(x, y);
return Foo.__wrap(ret);
}
sqr_distance() {
var ret = wasm.foo_sqr_distance(this.ptr);
return ret;
}
}
export class Foo {
free(): void;
static new(x: number, y: number): Foo;
sqr_distance(): number;
x: number;
y: number;
}
Reference Typesが導入されればこの限りではないようだが、現状 WebAssembly で関数の引数/返値で扱えるのは数値型 (i32, i64, f32, f64) のみとなっている。したがって構造体は線形メモリ上にそのデータが保持され、JS 側では .ptr
にそのアドレス(i32)を保持する形で表現されている。この関係で class Foo
には線形メモリからアロケートされたデータを解放する free()
メソッドが生えている。それ以外については、Rustの構造体はJSのクラスにうまくマップされている。グルーコード上のクラスは、WebAssemblyの関数をうまく呼ぶ薄いラッパークラスとなっている。
wasmはいきなり3000行ほどに膨らんだ。これはアロケータを伴うためだろう。いくつか抜粋して見てみる。
...
(func $__wbg_get_foo_x (type $t0) (param $p0 i32) (result i32)
block $B0
local.get $p0
if $I1
local.get $p0
i32.load
i32.const -1
i32.eq
br_if $B0
local.get $p0
i32.load offset=4
return
end
call $f26
unreachable
end
call $f27
unreachable)
...
最初のifはNULLチェック、次の br_if
はわからない...が最適化前のwasmを見ると WasmRefCell
らしきものが挟まってる?
...
(func $foo_new (type $t2) (param $p0 i32) (param $p1 i32) (result i32)
local.get $p0
local.get $p1
call $f9)
...
(func $f9 (type $t2) (param $p0 i32) (param $p1 i32) (result i32)
(local $l2 i32)
call $f1
local.tee $l2
i32.eqz
if $I0
i32.const 12
i32.const 4
i32.const 1048724
i32.load
local.tee $p0
i32.const 1
local.get $p0
select
call_indirect (type $t1) $T0
unreachable
end
local.get $l2
local.get $p1
i32.store offset=8
local.get $l2
local.get $p0
i32.store offset=4
local.get $l2
i32.const 0
i32.store
local.get $l2)
...
アロケート、 x
, y
をストアしているっぽい。
Function
WebAssemblyにCやRustのような関数ポインタ型は存在しない。値としての関数はどうコンパイルされるか。
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn f(x: i32) -> i32 {
(if x % 2 == 0 { |n| n * n } else { |n| n * 2 })(x)
}
(module
(type $t0 (func (param i32) (result i32)))
(func $f (type $t0) (param $p0 i32) (result i32)
local.get $p0
i32.const 1
i32.const 2
local.get $p0
i32.const 1
i32.and
select
call_indirect (type $t0) $T0)
(func $f1 (type $t0) (param $p0 i32) (result i32)
local.get $p0
local.get $p0
i32.mul)
(func $f2 (type $t0) (param $p0 i32) (result i32)
local.get $p0
i32.const 1
i32.shl)
(table $T0 3 3 funcref)
(memory $memory 17)
(export "memory" (memory 0))
(export "f" (func $f))
(elem $e0 (i32.const 1) $f2 $f1)
(data $d0 (i32.const 1048576) "\04"))
まず、2つの匿名の関数は $f1
$f2
として定義されている。実装は一目瞭然。
次にtableの定義がある。テーブルは特定の参照型(reference type)のベクトルで、ここではサイズ3で固定のfuncrefを持つテーブルを定義している。elemによってこのインデックス0 (デフォルト) のテーブル (= $T0
) のオフセット1からの初期値を $f2
$f1
としている。
selectは3番目のオペランドが0であるかどうかで1, 2番目のオペランドを選択する命令で、これによってelemに対応するインデックスを得ている。最後に call_indirect
で間接的な関数呼び出しを行う。 call_indirect
はオペランドをテーブルのインデックスとして解釈し、テーブルのそのインデックスの関数の型を検証し (失敗したらtrap)、その関数を呼び出す。ここまでインデックスと呼んできたが、テーブル (ここでは $T0
) も線形メモリとは独立したアドレス空間として考えると、アドレスと呼んで差し支えない。
ここまでに、WebAssemblyのモジュールを構成する定義がほぼ全て出てきた。出てきていないものにStart Functionの定義があるが、これはwasm-bindgenのstartオプションで確認できる。
Passing Rust Closures to Imported JavaScript Functions
use wasm_bindgen::prelude::*;
#[wasm_bindgen(inline_js = "export function twice(f, x) { return f(f(x)); }")]
extern "C" {
fn twice(f: &dyn Fn(i32) -> i32, x: i32) -> i32;
}
#[wasm_bindgen]
pub fn f() -> i32 {
twice(&|x| x * x, 5)
}
#[wasm_bindgen]
pub fn g(y: i32) -> i32 {
twice(&move |x| x * y, 5)
}
import { twice } from './snippets/wasm-sandbox-319714d67290d68b/inline0.js';
import * as wasm from './index_bg.wasm';
function getUint8Memory0() { ... }
function getStringFromWasm0(ptr, len) { ... }
export function f() {
var ret = wasm.f();
return ret;
}
export function g(y) {
var ret = wasm.g(y);
return ret;
}
export function __wbg_twice_786353df862e9e2a(arg0, arg1, arg2) {
try {
var state0 = {a: arg0, b: arg1};
var cb0 = (arg0) => wasm.wasm_bindgen__convert__closures__invoke1__h0b5bc81d3b905e9a(state0.a, state0.b, arg0);
var ret = twice(cb0, arg2);
return ret;
} finally {
state0.a = state0.b = 0;
}
};
export function __wbindgen_throw(arg0, arg1) { ... }
#[wasm_bindgen(inline_js = "...")]
はそのまま index_bg.js
がimportするモジュールに定義される。Rust側(WebAssembly側)のコードが呼び出す twice
の実体は __wbg_twice_786353df862e9e2a: (a: number, b: number, c: number) => number
となっている。
JSグルーコード側の __wbg_twice_786353df862e9e2a
の実装を見ると、 &dyn Fn(i32) -> i32
は最初の2引数 (a, b): (i32, i32)
に対応していることがわかる。JS側からのこのコールバックの呼出しは arg => wasm_bindgen__convert__closures__invoke1__h0b5bc81d3b905e9a(a, b, arg)
の形で行われている。wasm側のinvoke1の実装は以下の通り:
...
(type $t0 (func (param i32 i32) (result i32)))
(type $t1 (func (param i32 i32 i32) (result i32)))
...
(func $wasm_bindgen__convert__closures__invoke1__h0b5bc81d3b905e9a (type $t1) (param $p0 i32) (param $p1 i32) (param $p2 i32) (result i32)
local.get $p0
i32.eqz
if $I0
i32.const 1048576
i32.const 48
call $./index_bg.js.__wbindgen_throw
unreachable
end
local.get $p0
local.get $p2
local.get $p1
i32.load offset=12
call_indirect (type $t0) $T0)
...
a
は環境情報で、 b
は関数のアドレスへのアドレスとなっている (load offset=12
して call_indirect
している, offset=12
はよくわからない)。 f
, g
で生成されるクロージャは以下:
...
(func $f4 (type $t0) (param $p0 i32) (param $p1 i32) (result i32)
local.get $p0
i32.load
local.get $p1
i32.mul)
...
(func $f6 (type $t0) (param $p0 i32) (param $p1 i32) (result i32)
local.get $p1
local.get $p1
i32.mul)
(func $f7 (type $t2) (param $p0 i32)
nop)
...
(table $T0 8 8 funcref)
...
(elem $e0 (i32.const 1) $wasm_bindgen__convert__closures__invoke1__h0b5bc81d3b905e9a $f7 $f6 $f6 $f7 $f4 $f4)
$f6
は f
側で生成されるクロージャで、環境 $p0
への参照がない。 $f4
は g
側で生成されるクロージャで、環境 $p0
を load
している。ここから環境情報もアドレスであることがわかる。
クロージャ生成部分。
(func $f (type $t4) (result i32)
i32.const 1048624
i32.const 1048624
i32.const 5
call $./index_bg.js.__wbg_twice_786353df862e9e2a)
(func $g (type $t5) (param $p0 i32) (result i32)
(local $l1 i32)
global.get $g0
i32.const 16
i32.sub
local.tee $l1
global.set $g0
local.get $l1
local.get $p0
i32.store offset=12
local.get $l1
i32.const 12
i32.add
i32.const 1048648
i32.const 5
call $./index_bg.js.__wbg_twice_786353df862e9e2a
local.get $l1
i32.const 16
i32.add
global.set $g0)
$f
は単にアドレス 1048624
を積んでいる (環境側のアドレスは使われない)。dataセグメントを見ると 0x3
に対応する。 $g
はグローバルなスタックポインタ $g0
を動かして領域を確保し、環境情報に $p0
をストア、その後アドレス 1048648
を積んでいる。dataセグメントを見ると 0x6
に対応する。