🌟

WebAssembly の GC Proposal とは何か / どこに向かおうとしてるのか

2022/03/10に公開

最初に

これは WebAssembly に GC が導入されるから紹介、という記事ではない。どちらかというと、WebAssembly GC の採用がどれだけ遠く、また GC がのればどんな言語でも wasm のコンパイルサイズが減って軽量になる、という夢を見ている人に、現実を見てもらうための記事になる。

WebAssembly GC Proposal (Team)は、それを実現するパーツを分割して仕様策定を進めていて、実際に GC が動き出すまでには数年かかるだろうし、自分の感覚的に、将来的に GC が採用されるかは五分五分といったところ。

ただ、 GC Proposal から派生した仕様郡は GC が採用されなかったとしても有意義なものばかりなので、本記事ではそれを紹介したい。

基本的にここを参照

https://github.com/WebAssembly/gc/blob/master/proposals/gc/Overview.md

Excuse

自分は低レベルプログラミングの経験が浅く、WebAssembly のために関係する仕様の勉強をはじめたので、この記事も色々と間違いがあると思う。

手書き wat (WebAssembly Text Format) をやってから、簡単なコンパイラを書いて Binary Encoding をして肌感を掴みつつある、ぐらいのステータス。

WebAssembly における GC 導入の目的

https://github.com/WebAssembly/gc/blob/master/proposals/gc/Overview.md#motivation

  • 高速な実行
  • 小さなモジュールサイズ
  • 多くの言語がそれを前提とした実装だから

Wasm の GC への要求

  • 構造体の配置と破棄
  • JavaScript 等の外界と共有するオブジェクトの管理

これを安全に、高速に、外部仕様に頼らず、スレッドセーフに行いたい。

アプローチ

既存の wasm に全てに影響があるわけではなく、あくまで拡張と位置づけられている。

  • (WebAssembly が現在持っている) 線形メモリと独立したヒープメモリを追加する
  • tuple, vector, unboxed scalar などの一般的なデータ構造を対象にする
  • 実装の簡略化のために、多少の動的なオーバーヘッドは許容する
  • 実行時の型情報を自明にする仕様を追加する

架空の言語による説明

このような(tsっぽい)架空の言語あったとする

type tup = (int, int, bool)
type vec3d = float[3]
type buf = {var pos : int, chars : char[]}

function f() {
  let t : tup = (1, 2, true)
  t.1
}

function g() {
  let v : vec3d = [1, 1, 5]
  v[1]
}

function h() {
  let b : nullable buf = {pos = 0, chars = "AAAA"}
  b.buf[b.pos]
}

これは wat(wasm text format) と GC Extension を使うと、次のように表現される

(type $tup (struct i64 i64 i32))
(type $vec3d (array (mut f64)))
(type $char-array (array (mut i8)))
(type $buf (struct (field $pos (mut i64)) (field $chars (ref $char-array))))

(func $f
  (struct.new $tup (i64.const 1) (i64.const 2) (i64.const 1))
  (let (local $t (ref $tup))
    (struct.get $tup 1 (local.get $t))
    (drop)
  )
)

(func $g
  (array.new $vec3d (i32.const 3) (f64.const 1))
  (let (local $v (ref $vec3d))
    (array.set $vec3d (local.get $v) (i32.const 2) (i32.const 5))
    (array.get $vec3d (local.get $v) (i32.const 1))
    (drop)
  )
)

(func $h
  (local $b (optref $buf))
  (local.set $b
    (struct.new $buf
      (i64.const 0)
      (array.new $char-array (i32.const 4) (i32.const 0x41))
    )
  )
  (array.get $buf
    (struct.get $buf $chars (local.get $b))
    (struct.get $buf $pos (local.get $b))
  )
  (drop)
)

(type $tup (struct i64 i64 i32))

struct 宣言で構造体を宣言する。

(struct.new $tup (i64.const 1) (i64.const 2) (i64.const 1))

struct.new によって構造体を生成する。これがヒープメモリに配置される。

function f() {
  let t : tup = (1, 2, true)
  t.1
}

ドキュメントの中で特に明示されないが、この関数の中では、おそらく t.1(=2) が return された時点で t への参照がなくなるので、GC 対象になるのだろう。 ref.cast のダウンキャストも使われない構造体メンバへの参照を切るために使われる気配を感じる。

議論を見てると、ヒープの実際の振る舞いは他の仕様が決まってから議論しようぜ、といった感じで、まだ言及が少ない。

ここまで来るとだいぶ高水準言語のような印象を受ける。

課題: 現在の仕様からの大幅な飛躍

この提案には現在の仕様からの大幅な飛躍がある。

まず、現在の wasm の仕様は i32, i64, f32, f64, externref(外部参照ポインタ) ぐらいしか扱えるものがない。

これらのGCセマンティクスを導入するためには、これらの仕様を定めないといけない。

  • 不変フィールド
  • 参照型
  • ダウンキャスト
  • 動的リンク
  • サブタイピング

これが決まったあとで、 struct のようなヒープへの構造体宣言が可能になる。
ヒープメモリのセマンティクスを考える前に、wat 内の構造体の仕様、それを関数で扱う仕様を進める必要がある。

ので、ここから大量の仕様に分割されたり、元からあった提案に GC 上の解釈を付与して仕様として議論するようになっていた。というのがここまでのあらすじ。

Proposal: Reference Type

https://github.com/WebAssembly/reference-types/blob/master/proposals/reference-types/Overview.md

外部から受け取ったポインタを wasm 内部で引き回す仕様。

現状では、あくまでポインタを知るだけなので、その中身を操作できるようになるわけではない。

例えば document.body みたいな複雑な構造体を wasm に渡すことは(現状)できないが、何らかの DOM 参照ということだけ知って引き回して、 import / export した関数同士で委譲して処理させたりできる。

externref がどういう構造を持っているか wasm が知ることができれば、将来的にその内部に触れるようになり、これは参照がどういう構造をもっているかの Interface Type に仕様が分割されている。

externref は既に採用されて実装されはじめている。 Rust の wasm-bindgen ではオプションを有効にすることで、グルーコードを減らすことに成功している。

Proposal: Interface Type

https://github.com/WebAssembly/interface-types/blob/main/proposals/interface-types/Explainer.md

i32 なプリミティブな値以外に、構造を定義して、扱えるようにする仕様。wasi インターフェースを決めた時点で、インターフェースが i32 まみれだったことで問題意識が生まれたらしい。

wasm / js 間の呼び出しオーバーヘッドのほとんどはこのインターフェースがうまく定義されてないことによるグルーコードで発生しているため、ここを定義することで問題が解消する可能性がある、とのこと。

https://github.com/WebAssembly/interface-types/blob/main/proposals/interface-types/Explainer.md#optimizing-calls-to-web-apis

この仕様では、外部から受け入れるモジュールや関数のインターフェースを定義できるようになる。

(adapter_module
  (import "duplicate" (adapter_func $dup (param string) (result string string)))
  (import "print" (adapter_func $print (param string)))
  (adapter_func (export "print_twice") (param string)
    call_adapter $dup
    call_adapter $print
    call_adapter $print
  )
)

adapter で提供された関数は call ではなく call_adapter を使って呼ぶ。

(adapter_func $return_one_of (param string string i32) (result string)
  i32.eqz
  (if (param string string) (result string)
    (then return)
    (else drop return))
)

adapter_func は adapter_module に対する実装っぽい。

ちょっとまだつかめてないので、詳細は略。詳しくはここ https://github.com/WebAssembly/interface-types/blob/main/proposals/interface-types/Explainer.md#adapter-modules

この仕様で libc に対して interface を書くとどうなるかみたいなのが書いてある。

https://github.com/WebAssembly/interface-types/blob/main/proposals/interface-types/Explainer.md#adapter-fusion

.wat ではなく、 インターフェースを記述したのが .wit というファイルで表現され、これを用いる rust のコードジェネレータ? が bytocedoalliance のリポジトリとしてあった。

https://github.com/bytecodealliance/wit-bindgen

(この辺は議論を見てると頻繁に仕様が変わるので、サンプルも恐らくこのまま実装はされない)

(この辺の仕様をみてると、 GC や Interface Types がそれぞれのグループが勝手に array や list 構造を考えているのがわかる)

この仕様は module link の仕様に依存されている。

Proposal: Type Imports

https://github.com/WebAssembly/proposal-type-imports/blob/master/proposals/type-imports/Overview.md

wasm の外部から型定義を import できるようにする仕様。

(import "file" "File" (type $File any))
(import "file" "open" (func $open (param $name i32) (result (ref $File))))
(import "file" "read_byte" (func $read (param (ref $File)) (result i32)))
(import "file" "close" (func $close (param (ref $File))))

今までは func や memory しか外部 import できなかったが、これによって型定義のインターフェースを受け取ることができる。また、type 以外に GC のために heap_type のセマンティクスも提案されている。

ここから派生して、 JS インターフェース側から型定義を実装するための WebAssembly.Type も提案されており、meeting notes をみるとここを tc39 のセマンティクスとどう合わせるか、みたいな議論がよく行われている。

Proposal: Module Linking

https://github.com/WebAssembly/module-linking

wasm またはその他(js) との動的リンク仕様。現在の .wasm はファイル単体で動作し、 import/export をするために JS のグルーコードが必要になっているが、これを .wasm 同士で解決できるようにしたり、 また ES Modules との binding も考えられている。

この仕様は interface type に依存していて、ここでまた adapter module が出てくる。

(adapter module
  (adapter module $A
    (module $B
      (func (export "one") (result i32) (i32.const 1))
    )
  )
  (module $C
    (func (export "two") (result i32) (i32.const 2))
  )
)

既に adapter_moduleadapter module か命令が異なっているが、meeting notes 見る限りは adapter module が最新な気がする。

また module 内で module を定義する inner module の提案もある。

(adapter module
  (module $Inner ...)
  (instance $left (instantiate $Inner))
  (instance $right (instantiate $Inner))
  (instance $pair
    (export "left" (instance $left))
    (export "right" (instance $right))
  )
)

JS Interface

import Foo from "./foo.wasm" as "wasm-module";
assert(Foo instanceof WebAssembly.Module);

ESM Import に追加されようとしている as だか assert だかの仕様で、wasm であることを明示して import する。

これらの仕様を使って GC がやること

自分の肌感

.wat を読むとわかるが、今までの wat 線形メモリのスタックを管理するローレベルなコードとは雰囲気が違い、高水準言語の S式表現といった趣がある。

実際今のプリミティブすぎる wasm のコードから、高水準言語に合わせた表現を用意することで、他の言語からのビルドサイズを減らす目的を強く感じる。ただ、実際にはそれぞれの言語の GC 特性やランタイム上のメタデータに依存した振る舞いがあるので、素直に 1:1 変換できるかというと、それは考えにくい。現実的には、既存言語の一部機能を落としたサブセット、もしくはこの仕様にフォーカスした専用言語で書かれるのではないか、と自分は考えている。

懸念点として、仕様が膨らんでいるせいで、これを愚直に実装すると「仕様が単純だからブラウザや様々なランタイムに対して中立な仕様でいられる」という今の wasm の姿はなくなり、実行エンジンのランタイム依存の特性が強く出てくることが予想される。

これを避けるには、複雑さを上回るメリットをこの仕様が示す必要があると個人的に思う。具体的には、java, dotnet, go などの単純な Hello World が10~MB になってしまってるコンパイラが、この仕様を使うと 100kb になる、というような…。

現時点では、wasm で軽量なモジュールを作成するには、手書き .wat または rust wasm32-unknown-unknow を no_std でビルド、かつ外部モジュールなしでやるしかないと思っている。そのために手書き wat-playground を作ったりしていた。

https://wat-playground.netlify.app/

Discussion