Closed11

rustc/wasm-bindgenの生成するWebAssemblyを観察する

yubrotyubrot

WebAssemblyの言語としての仕様を理解するため、Rustのコードがrustc/wasm-bindgenによってWebAssemblyにどのようにコンパイルされるかを延々と試したメモ。Mapping High Level Constructs to LLVM IRを意識している。

ビルドにはwasm-packを用いているため、出力はnpmパッケージの形になる。また .wasm ファイルはwasm-optによる最適化がかかった形となる。

yubrotyubrot

空のモジュール

lib.rs
// nothing
index_bg.wasm
(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.

yubrotyubrot

関数(1): i32 + i32

lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn foo(a: i32, b: i32) -> i32 {
    a + b
}
index_bg.wasm
(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"))
index_bg.js
import * as wasm from './index_bg.wasm';
...
export function foo(a, b) {
    var ret = wasm.foo(a, b);
    return ret;
}
index.d.ts
...
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) している。

脚注
  1. どこかで読んだがSpecから出典を発見できず、Spec的にはこの制限は無いかもしれない ↩︎

yubrotyubrot

関数(2): (i64, i64) -> i32

lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn foo(a: i64, b: i64) -> i32 {
    a as i32 + b as i32
}
index_bg.wasm
(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"))
index_bg.js
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;
}
index.d.ts
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 の最適化によって消えている)。

yubrotyubrot

関数(3): i32 -> i64

lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn foo(a: i32) -> i64 {
    a as i64 * 10
}
index_bg.wasm
(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"))

index_bg.js
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);
    }
}

index.d.ts
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 のような相対位置を取れる命令も見られる。

脚注
  1. インデックスとしてはパラメータに次ぐインデックスが振られる ↩︎

yubrotyubrot

export

lib.rs
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)
}
index_bg.wasm
(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"))
index_bg.js
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によって生成されるパッケージが別のパッケージに依存したい場合はどうする?
yubrotyubrot

wasm-opt, wabtが最適化パスも提供しているのは違和感があるなと調べたらwabtではなくbinaryenによって提供されているツールだった。GrainやAssemblyScriptはbinaryenを用いてWebAssemblyへのコンパイルを行っているとのこと。binaryen.jsのhello-world.jsなどを見るとかなり手軽で、このあたり色々活用方法がありそうだなあとか

yubrotyubrot

enum

#[wasm_bindgen] がサポートするのはC-likeなenumのみの様子。

lib.rs
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,
    }
}
index_bg.wasm
(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"))
index_bg.js
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));
};
index.ts
...
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セグメントは統合されていた。

yubrotyubrot

struct

lib.rs
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
    }
}
index_bg.js
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;
    }
}
index.d.ts
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行ほどに膨らんだ。これはアロケータを伴うためだろう。いくつか抜粋して見てみる。

index_bg.wasm
...
  (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 らしきものが挟まってる?

index_bg.wasm
...
  (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 をストアしているっぽい。

yubrotyubrot

Function

WebAssemblyにCやRustのような関数ポインタ型は存在しない。値としての関数はどうコンパイルされるか。

lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn f(x: i32) -> i32 {
    (if x % 2 == 0 { |n| n * n } else { |n| n * 2 })(x)
}
index_bg.wasm
(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オプションで確認できる。

yubrotyubrot

Passing Rust Closures to Imported JavaScript Functions

lib.rs
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)
}
index_bg.js
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の実装は以下の通り:

index_bg.wasm
...
  (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 で生成されるクロージャは以下:

index_bg.wasm
...
  (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)

$f6f 側で生成されるクロージャで、環境 $p0 への参照がない。 $f4g 側で生成されるクロージャで、環境 $p0load している。ここから環境情報もアドレスであることがわかる。

クロージャ生成部分。

index_bg.wasm
  (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 に対応する。

このスクラップは2021/08/06にクローズされました