Closed20

Wasm Component CanonicalABI を眺める

tanishikingtanishiking

https://zenn.dev/tanishiking/scraps/7aa5bcbd6902c2
の続き。

自分は WasmVM 開発者ではなくて、コンパイラ開発者なのでここはなんかしらんけどVMが頑張ってくれるらしいみたいなところは適当に読み飛ばすかもしれない。

まずは canon liftcanon lower について調べる

lift wraps a core function (of type core:functype) to produce a component function (of type functype) that can be passed to other components.
lower wraps a component function (of type functype) to produce a core function (of type core:functype) that can be imported and called from Core WebAssembly code inside the current component.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/Explainer.md#canonical-abi

とあるように、canon lift は core function を component function にラップし、conon lower は component function から core function を produce する。

https://zenn.dev/tanishiking/scraps/7aa5bcbd6902c2 で生成した例では canon lift / canon lower は以下のように使われる。

  (core instance (;4;) (instantiate 0
      (with "wasi:io/error@0.2.0" (instance 1))
      (with "wasi:cli/stdout@0.2.0" (instance 2))
      (with "wasi:io/streams@0.2.0" (instance 3))
    )
  )

  (type (;7;) (result))
  (type (;8;) (func (result 7)))
  (alias core export 4 "wasi:cli/run@0.2.0#run" (core func (;8;)))
  (func (;3;) (type 8) (canon lift (core func 8)))
  (type (;4;)
    (instance
      (alias outer 1 3 (type (;0;)))
      (export (;1;) "output-stream" (type (eq 0)))
      (type (;2;) (own 1))
      (type (;3;) (func (result 2)))
      (export (;0;) "get-stdout" (func (type 3)))
    )
  )
  (import "wasi:cli/stdout@0.2.0" (instance (;2;) (type 4)))

  (alias export 2 "get-stdout" (func (;0;)))
  (core func (;1;) (canon lower (func 0)))
  • 上の (func (;3;) (type 8) (canon lift (core func 8))) は abbreviation
  • (func $g ...type... (canon lift ...)) ≡ (canon lift ... (func $g ...type...)) という abbreviation が定義されているので
  • これは (canon lift (core func 8) (func (;3;) (type 8)) に等しい

canon lower も同様で

  • (core func (;1;) (canon lower (func 0)))
  • (core func $h (canon lower ...)) ≡ (canon lower ... (core func $h)) という abbreviation が定義されていて
  • (canon lower (func 0) (core func (;1;))) と同義
tanishikingtanishiking

canon lift / canon lower

https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#canon-lift(canon lift $callee:<funcidx> $opts:<canonopt>* (func $f (type $ft))) の挙動について説明がある。細かいところは置いておいて、全体的にどういう挙動になるのかを見ていく。まず validation についていくつか書いているが、いったんこれはスキップ

When instantiating component instance $inst:
Define $f to be the partially-bound closure canon_lift($opts, $inst, $ft, $callee)

とある。$inst はその canon lift を含む component の instance のことで、component を instance 化するときにそこに含まれる (canon lift $callee:<funcidx> $opts:<canonopt>* (func $f (type $ft))) は、partially-bound closure である canon_lift($opts, $inst, $ft, $callee)$f に bind する。(ここで (canon lift) は Wasm component の関数のことで、canon_lift はそれにより返される closure のことを指す。違うものなので注意)。

同様に、canon_lower (canon lower $callee:<funcidx> $opts:<canonopt>* (core func $f)) を見てみると

When instantiating component instance $inst:
Define $f to be the partially-bound closure: canon_lower($opts, $ft, $callee)

とあるように、これも component を instanciate するときに、canon_lower という closure を $f に bind する。($ft$callee の型だろうか)

canon_lift / canon_lower / Task

次に、canon liftcanon lower によって、component instantiate 時に生成されるclosureである canon_liftcanon_lower を見てみる。

これはネタバレなんだが、そうか...component (instance?) の中では Core Wasm Function はすべて canon_lower で、Component Function は canon_lift なんだ(多分?)

canon_lift

$opts, $inst, $ft, $callee がキャプチャされている closure

async def canon_lift(opts, inst, ft, callee, caller, on_start, on_return, on_block = default_on_block)
  • $opts - CanonicalABIOptions (canon lift) で指定可能
  • $inst - ComponentInstance その (canon lift) を含む component の instance
  • $ft - (canon lift) で指定される lift された component function の型
  • $callee - lift する対象の core wasm function。自分の理解ではruntimeでは後述する canon_lower としてモデリングされているという理解。

この closure は 4つの以下の引数を受け取る。

  • caller: the caller's Task or, if this lifted function is being called by the host, None
  • on_start: a nullary function that must be called to return the caller's arguments as a list of component-level values
  • on_return: a unary function that must be called after on_start, passing the list of component-level return values
  • on_block: a unary async function taking an asyncio.Future to await, and returning the future's result. This function will be called (by the Canonical ABI) instead of raw await for any blocking operation.

これらの4つの引数についてはまだあんまりよく分かりませんね。一旦 canon_lower も見ていく

canon_lower

async def canon_lower(opts, ft, callee, task, flat_args)

このうち opts, ft, callee はキャプチャされている。

  • $opts - CanonicalABIOptions (canon lift) で指定可能
  • $ft - syntax には現れないが、多分 $f (component function) の型
  • $callee - lower する対象の component function。自分の理解ではruntimeでは canon_lift としてモデリングされているという理解。

で、この closure は以下2つの runtime arguments を受け取るらしい

  • task: the Task that was created by canon_lift when entering the current component instance
  • flat_args: the list of core values passed by the core function calller

flat_args というのは、component function の arguments を core wasm function のための arguments に変換したもの。どういう変換ルールになっているかはあとで詳しく述べる。

Task というもの、これ canon_liftcaller 引数にも出てきたな?

Task

Task は python のモデル上では以下のように定義されており

class Task(CallContext):
  ft: FuncType # このタスクに関連付けられた関数タイプ。(FuncType の型が見当たらないが)
  caller: Optional[Task] # このタスクを呼び出したタスク?。host の場合は None
  on_return: Optional[Callable] # このタスクが返るときに呼び出されるコールバック関数。
  on_block: OnBlockCallback # ブロッキング操作が発生したときに呼び出されるコールバック関数。async 用?
  need_to_drop: int # ドロップする必要のあるリソースの数。
  events: list[EventCallback] # このタスクに関連付けられたイベントのリスト。
  has_events: asyncio.Event # イベントが発生したかどうかを示すフラグ。

これだけ見てもナンノコッチャわからない(まあ sync の場合は on_block 以下はそれほど考えなくても良いはず)が、これに生えているいくつかのメソッドが重要になる。canon_liftcanon_lower でいくつかのメソッドが実行されているので、それを見ていく。

tanishikingtanishiking

この後当たり前のように出てくるので先に軽く触れておくと、Component Model には Flattening / Flat Lifting / Flat Lowering という処理が定義されている。これらは component function の型を core wasm function の型に変換したり、Component-level の値と core wasm level の値との相互変換の方法を定義している。

  • def flatten_functype(opts, ft, context) は 与えられた component function type $ft を core wasm function type CoreFuncType に変換し
  • def lift_flat(cx, vi, t) は core wasm value vi と component level type $t を受け取って、vi を component level type $t に合うように変換する
  • def lower_flat(cx, v, t) は component-level value v と、その v の型(component-level)を受け取って、core wasm value に変換する

詳しい変換(重要)はあとで詳しく見ていく

tanishikingtanishiking

canon_lift 詳細

async def canon_lift(opts, inst, ft, callee, caller, on_start, on_return, on_block = default_on_block):
  task = Task(opts, inst, ft, caller, on_return, on_block)
  flat_args = await task.enter(on_start)
  flat_ft = flatten_functype(opts, ft, 'lift')
  assert(types_match_values(flat_ft.params, flat_args))
  if opts.sync:
    flat_results = await call_and_trap_on_throw(callee, task, flat_args)
    assert(types_match_values(flat_ft.results, flat_results))
    task.return_(flat_results)
    if opts.post_return is not None:
      [] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
  else:
    # 省略 (asyncは一旦見ない)
  task.exit()

flat_args = await task.enter(on_start)

Task#enteron_start を受け取って flat_args (core wasm function にわたすための arguments) を返す。

on_start が何だったかというと、実行するとその component function への arguments (component-level values) が返ってくる関数。

on_start: a nullary function that must be called to return the caller's arguments as a list of component-level values

Task#enter を見てみる。

  async def enter(self, on_start):
    assert(current_task.locked())
    self.trap_if_on_the_stack(self.inst)
    # ここは基本的に async のための処理(?)
    # 詳しい条件を見ていないが、backpressure が必要化どうかチェック
    # もしタスクが即座に実行可能なら、このif-bodyはスキップ
    if not self.may_enter(self) or self.inst.pending_tasks:
      f = asyncio.Future()
      self.inst.pending_tasks.append((self, f))
      # on_block を呼び出して、タスクをブロックする
      await self.on_block(f)
      assert(self.inst.starting_pending_task)
      self.inst.starting_pending_task = False
    if self.opts.sync:
      assert(self.inst.interruptible.is_set())
      self.inst.interruptible.clear()
    self.inst.num_tasks += 1
    # ここが重要。lower_flat_value で arguments を flatten している
    return lower_flat_values(self, MAX_FLAT_PARAMS, on_start(), self.ft.param_types())

どうやら enterlower_flat_values を実行して、component-level の arguments を core function の arguments に変換する(コンパイラ側としてはここが一番気になる)。詳細はあとで見ていく。

on_start: a nullary function that must be called to return the caller's arguments as a list of component-level values

だったので on_start() で arguments を渡していることが分かる。

flat_results = await call_and_trap_on_throw(callee, task, flat_args)

task.enter により flat_args が得られたら、次はliftしたcore functionを call_and_trap_on_throw で実行する。

これは caleee を実行して、その結果 (Wasm Exceptionの) uncatched exception が投げられた場合は trap するというもの。
なんか calee(task, args) から calleeTask となんかargumentsを受け取って処理を実行するもの? あ、これ canon_lower だ!(多分)

多分 lift 元となった Core Wasm Function は canon_lower closure としてモデリングされていて、それを callee(task, args) で実行している?

async def call_and_trap_on_throw(callee, task, args):
  try:
    return await callee(task, args)
  except CoreWebAssemblyException:
    trap()

task.return_(flat_results)

次に call_and_trap_on_throw で帰ってきた flat_resultstask.return_(flat_results) する。return_ は Core Wasm function から返された結果を lift_flat_values で component-level value にliftし、(canon lift) を呼び出した際に指定した on_return コールバック関数に渡す。

on_return: a unary function that must be called after on_start, passing the list of component-level return values

こういうcallback関数で返り値を渡すやり方にしているのは、非同期処理の結果を受け取れるようにするためらしい。

  def return_(self, flat_results):
    trap_if(not self.on_return)
    if self.opts.sync:
      maxflat = MAX_FLAT_RESULTS
    else:
      maxflat = MAX_FLAT_PARAMS
    ts = self.ft.result_types()
    # ここで実行結果の core wasm values を component-level values に lift する
    vs = lift_flat_values(self, maxflat, CoreValueIter(flat_results), ts)
    self.on_return(vs)
    self.on_return = None

この on_return ってやつにはどういうものが入るんだい? 答えは canon_lower の中にありそうなんだけど、一旦最後まで canon_lift を見る。

task.exit()

(post_return などは一旦置いておいて) 最後に canon_lifttask.exit を実行する

task.exit はこんな感じで、component instance に対して Task が完了したことを通知し、pending していた次のTaskが実行できる状態にするっぽい。詳しくは見てない。

  def exit(self):
    # ...
    self.inst.num_tasks -= 1
    if self.opts.sync:
      assert(not self.inst.interruptible.is_set())
      self.inst.interruptible.set()
    self.maybe_start_pending_task()

次は canon_lower ここで on_return などの謎が多少わかるはずだ

tanishikingtanishiking

canon_lower 詳細

ここまでで見たように、canon_lift は実際に Core Wasm Function を直接実行するわけではなくて、bind された callee (core wasm function) と、実行時に受け取った on_start, on_return などを呼び出して間接的に実行しているだけのモデル。

直接 Core Wasm Function の実行をしているのは canon_lower。詳しく見ていく

(canon lower $callee:<funcidx> $opts:<canonopt>* (core func $f))

# 以下の引数のうち、`opts`, `ft`, `callee` は `(canon lower)` により決定しており
# task と flat_args が実行時引数となる
# task: the Task that was created by canon_lift when entering the current component instance
# flat_args: the list of core values passed by the core function calller
async def canon_lower(opts, ft, callee, task, flat_args):
  trap_if(not task.inst.may_leave)
  flat_ft = flatten_functype(opts, ft, 'lower')
  assert(types_match_values(flat_ft.params, flat_args))
  subtask = Subtask(opts, ft, task, flat_args)
  if opts.sync:
    await task.call_sync(callee, task, subtask.on_start, subtask.on_return)
    flat_results = subtask.finish()
  else:
    # async は省略
  assert(types_match_values(flat_ft.results, flat_results))
  return flat_results

flatten_functype とかはまあ良いとして、subtask = Subtask(opts, ft, task, flat_args) なるものがあり、task.call_syncsubtask.on_startsubtask.on_return が渡されている。この on_starton_return の具体的にどういうものなのか知りたい!

Subtask

Importantly, the task field of a Subtask refers to the current task which called canon lower, thereby linking all subtasks to their supertask, maintaining the (possibly asynchronous) call tree.

class Subtask(CallContext):
  ft: FuncType # canon lowerで指定された関数型
  flat_args: CoreValueIter # Core WebAssembly Function に渡される引数のリスト
  flat_results: Optional[list[Any]] # Core WebAssembly Function から返される結果のリスト
  state: CallState # サブタスクの状態 (STARTING、STARTED、RETURNED、DONE)
  lenders: list[ResourceHandle] # サブタスクは、親タスクからリソースを借用できる。借用されたリソースは、このフィールドに追記されていき、サブタスクが完了すると、release_lendersメソッドによって、借用したすべてのリソースが解放
  notify_supertask: bool # 非同期呼び出しかつ blocking された場合? true になる。今回は興味ない
  enqueued: bool

  def __init__(self, opts, ft, task, flat_args):
    super().__init__(opts, task.inst, task)
    self.ft = ft
    self.flat_args = CoreValueIter(flat_args)
    self.flat_results = None
    self.state = CallState.STARTING
    self.lenders = []
    self.notify_supertask = False
    self.enqueued = False

class CallContext:
  opts: CanonicalOptions
  inst: ComponentInstance
  task: Task # Subtask においては、呼び出し元のTask

で、on_starton_return はどう定義されているのかというと

  def on_start(self):
    assert(self.state == CallState.STARTING)
    # サブタスクの状態を STARTED に更新。呼び出し先関数が実行を開始したことを表す
    self.state = CallState.STARTED
    # サブタスクの状態が変わったことを通知
    self.maybe_notify_supertask()
    # flat では flat した結果の argumetns が多すぎる場合は linear memory を介したデータの受け渡しに fallback する
    # MAX_FLAT_PARAMS = 16
    # なんで async だと 1 なの? まあ今回は気にしない
    max_flat = MAX_FLAT_PARAMS if self.opts.sync else 1
    ts = self.ft.param_types()
    # flat_args を lift して返す
    # https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#lifting-and-lowering-values
    return lift_flat_values(self, max_flat, self.flat_args, ts)

  def on_return(self, vs):
    assert(self.state == CallState.STARTED)
    # サブタスクの状態を RETURNED に更新。呼び出し先関数が結果を返したことを示す
    self.state = CallState.RETURNED
    self.maybe_notify_supertask()
    # MAX_FLAT_RESULTS = 1
    max_flat = MAX_FLAT_RESULTS if self.opts.sync else 0
    ts = self.ft.result_types()
    # Subtask の flat_results フィールドに lower した result values をセット
    self.flat_results = lower_flat_values(self, max_flat, vs, ts, self.flat_args)

なるほど、on_return は Subtask の flat_results フィールドに lower した結果を返すのか。

call_sync

Subtask がどういうものかなんとなく分かったので、また canon_lower に話を戻す

  if opts.sync:
    await task.call_sync(callee, task, subtask.on_start, subtask.on_return)
    flat_results = subtask.finish()
  async def call_sync(self, callee, *args):
    if self.inst.interruptible.is_set():
      self.inst.interruptible.clear()
      await callee(*args, self.on_block)
      self.inst.interruptible.set()
    else:
      await callee(*args, self.on_block)

これで component-level function (canon_lift) を呼び出す。これにより on_return を介して flat_results に結果が入る

finish

最終的に on_return によってセットされた flat_results を返す。

  def finish(self):
    assert(self.state == CallState.RETURNED)
    self.state = CallState.DONE
    if self.notify_supertask:
      self.maybe_notify_supertask()
    else:
      self.release_lenders()
    return self.flat_results

つまり

  • (☆) canon_lower が core wasm level arguments を実行時引数で受取
  • Subtask#on_start()lift_flat_values で component-level values に変換
  • Task#call_sync から component-level function (canon_lift) を呼び出し
    • この task: Taskcanon_lower が受け取る実行時引数なんだが、どんなもの? 呼び出し元
  • canon_liftTask#enteron_start で component-level argumets を渡し (ここから別コンポーネントの関数呼び出し)
  • Task#enterlower_flat_values で core wasm level arguments に変換
  • call_on_trap_and_throwcalleeTask と core wasm level arguments を渡す (☆)につながる(?)
  • 関数の返り値(core wasm value) は lift_flat_values を通して on_return に渡され
  • on_returnlower_flat_values でまた値を core wasm values に戻して flat_results にセットする

なんかよくわからなくなってきた。ぐるぐる回ってる。と思ったけどこれ、task とか subtask が連鎖していく感じなのか?

なんか on_return 渡すのに一度 component-level values に変換したりしてて無駄っぽいけど、なんか理由あるのかな?

tanishikingtanishiking

例えば WASIp2 では wasi:cli/runrun がエンドポイントとなるので、そこからの流れを考えていみる。

https://github.com/WebAssembly/WASI/blob/dbe2733c6b0ee04609d877770b549adef99daacd/wasip2/cli/run.wit#L1-L6

  • まず VM が は component-level function である wasi:cli/runrun を呼び出す
    • つまり(多分) canon_lift の実行時引数にVMがいろんな値を渡す形になるのかな
    • 例えば前の例では (func (;3;) (type 8) (canon lift (core func 8))) が回り回って run として export されている
  • canon_liftwasi:cli/run run の実行に対応する Task を作る
  • canon_lifttask.enterwasi:cli/run run の実行を開始、lower_flat_valuesrun の引数を core wasm value に変換(まあrunは引数受け取らないんだが)
  • call_and_trap_on_throw(callee, task, flat_args) で underlying core wasm function を実行 (alias core export 4 "wasi:cli/run@0.2.0#run" (core func (;8;)))
    • つまり canon_lower (なはず?) (canon lower) は現れないけど alias core は暗黙的に canon_lower としてモデルされるのでは?
  • そこからの処理は core wasm module で実行されるはず
  • いや、(canon lower) で module 外の component から渡された関数を実行すると Subtask が生成されて〜上のコメントに書いたような流れが発生するのか
  • 最終的に (VMから渡された) on_returnflat_results を書き込む

ちょっと分かってきた気がする?次はFlatteningについて調べる

tanishikingtanishiking

次に lift_flat_valueslower_flat_values による core wasm values と component-level values の相互変換について調べていく。まずは lower_flat_values から見ていく

lower_flat_values

lower_flat_values function defines how to lower a list of component-level values vs of types ts into a list of core values.
As already described for flatten_functype above, lowering handles the greater-than-max_flat case by either allocating storage with realloc or accepting a caller-allocated buffer as an out-param:
https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#lifting-and-lowering-values

def lower_flat_values(cx, max_flat, vs, ts, out_param = None):
  cx.inst.may_leave = False
  flat_types = flatten_types(ts)
  if len(flat_types) > max_flat:
    flat_vals = lower_heap_values(cx, vs, ts, out_param)
  else:
    flat_vals = []
    for i in range(len(vs)):
      flat_vals += lower_flat(cx, vs[i], ts[i])
  cx.inst.may_leave = True
  return flat_vals
  • flatten_types は component-level type を core wasm type に lower するのだが、実際に値がどのように変換されるのかを見ないと型の変換だけ見てもなんのこっちゃ分からないので一旦読み飛ばす
    • ちなみに実装はこんな感じで def flatten_types(ts): return [ft for t in ts for ft in flatten_type(t)] component-level types list ts を受け取って、core wasm types の list を返す
  • flatten_types により max_flat 以上の引数を受け取る関数の場合は lower_heap_values という関数を使って、linear memory などを用いた引数の引き渡しをする(なんで多いとmemoryを使うんだろうね?パフォーマンスのため?)
    • まずは簡単のために、len(flat_types) <= max_flatのケースだけを考える
  • lower_flat_values の受け取る引数は
    • vs: コンポーネントレベルの値のリスト
    • ts: コンポーネントレベルの値の型情報リスト
    • out_param: 出力パラメータ (オプション)
  • 各値と型に対して lower_flat を実行しているだけ

lower_flat

def lower_flat(cx, v, t):
  match despecialize(t):
    case BoolType()         : return [int(v)]
    case U8Type()           : return [v]
    case U16Type()          : return [v]
    case U32Type()          : return [v]
    case U64Type()          : return [v]
    case S8Type()           : return lower_flat_signed(v, 32)
    case S16Type()          : return lower_flat_signed(v, 32)
    case S32Type()          : return lower_flat_signed(v, 32)
    case S64Type()          : return lower_flat_signed(v, 64)
    case F32Type()          : return [maybe_scramble_nan32(v)]
    case F64Type()          : return [maybe_scramble_nan64(v)]
    case CharType()         : return [char_to_i32(v)]
    case StringType()       : return lower_flat_string(cx, v)
    case ListType(t, l)     : return lower_flat_list(cx, v, t, l)
    case RecordType(fields) : return lower_flat_record(cx, v, fields)
    case VariantType(cases) : return lower_flat_variant(cx, v, cases)
    case FlagsType(labels)  : return lower_flat_flags(v, labels)
    case OwnType()          : return [lower_own(cx, v, t)]
    case BorrowType()       : return [lower_borrow(cx, v, t)]

まず despecialize を見た後、それぞれのcaseが何をするのか見ていく

despecialize

specialized value types は他の基本的な型の特殊化として定義される型

                    (tuple <valtype>*) ↦ (record (field "𝒊" <valtype>)*) for 𝒊=0,1,...
                    (flags "<label>"*) ↦ (record (field "<label>" bool)*)
                     (enum "<label>"+) ↦ (variant (case "<label>")+)
                    (option <valtype>) ↦ (variant (case "none") (case "some" <valtype>))
(result <valtype>? (error <valtype>)?) ↦ (variant (case "ok" <valtype>?) (case "error" <valtype>?))
                                string ↦ (list char)

Specialized value types

def despecialize(t):
  match t:
    case TupleType(ts)       : return RecordType([ FieldType(str(i), t) for i,t in enumerate(ts) ])
    case EnumType(labels)    : return VariantType([ CaseType(l, None) for l in labels ])
    case OptionType(t)       : return VariantType([ CaseType("none", None), CaseType("some", t) ])
    case ResultType(ok, err) : return VariantType([ CaseType("ok", ok), CaseType("error", err) ])
    case _                   : return t

String と flag は 展開先の型とは異なる方法で lift/lower されるため、despecialize の対象から外れている。

The specialized value types string and flags are missing from this list because they are given specialized canonical ABI representations distinct from their respective expansions.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#despecialization

lower_flat_signed

    case S8Type()           : return lower_flat_signed(v, 32)
    case S16Type()          : return lower_flat_signed(v, 32)
    case S32Type()          : return lower_flat_signed(v, 32)
    case S64Type()          : return lower_flat_signed(v, 64)
  • lower_flat_signed は、component-level の符号付き整数を符号なしのcore wasm i32 に変換
  • コンポーネントレベルの値は、有効な範囲内であると仮定
  • Core wasm の i32 値は、内部的には常に符号なし整数として表現
    • The conversion logic here assumes that i32 values are always represented as unsigned Python ints and thus lifting to a signed type performs a manual 2s complement conversion in the Python

    • そのため、符号なし整数を lower_flat_signed 関数で変換する必要はない。符号付き整数値は、2の補数表現をを使って、符号なしのCore Wasm i32 値に変換
    • これどうやっても lift するのかというと、component type が一緒に与えられるので問題ない
def lower_flat_signed(i, core_bits):
  if i < 0:
    i += (1 << core_bits)
  return [i]

maybe_scramble_nan32/64

def maybe_scramble_nan32(f):
  if math.isnan(f):
    if DETERMINISTIC_PROFILE:
      f = core_f32_reinterpret_i32(CANONICAL_FLOAT32_NAN)
    else:
      f = core_f32_reinterpret_i32(random_nan_bits(32, 8))
  assert(math.isnan(f))
  return f

NaN に対する処理がいくつかあるが、基本的には component-level value をそのまま使う

char_to_int

    case CharType()         : return [char_to_i32(v)]
def char_to_i32(c):
  i = ord(c)
  assert(0 <= i <= 0xD7FF or 0xD800 <= i <= 0x10FFFF)
  return i

char は Unicode Scalar value
high-surrogate とかは? string では扱えるはずだが...

lower_flat_string(cx, v)

Since strings and variable-length lists are stored in linear memory, lifting can reuse the previous definitions; only the resulting pointers are returned differently (as i32 values instead of as a pair in linear memory). Fixed-length lists are lowered the same way as tuples (via lower_flat_record below).

def lower_flat_string(cx, v):
  ptr, packed_length = store_string_into_range(cx, v)
  return [ptr, packed_length]

どうやら string は linear memory に保存し、core wasmではそのポインタ(linear memory上のoffset)とlengthによって表現されるらしい。store_string_into_rangeは? (とりあえず cx.opts.string_encoding = 'utf-16' のケースだけを考える)

def store_string_into_range(cx, v):
  src, src_encoding, src_tagged_code_units = v

  if src_encoding == 'latin1+utf16':
    if bool(src_tagged_code_units & UTF16_TAG):
      src_simple_encoding = 'utf16'
      src_code_units = src_tagged_code_units ^ UTF16_TAG
    else:
      src_simple_encoding = 'latin1'
      src_code_units = src_tagged_code_units
  else:
    src_simple_encoding = src_encoding
    src_code_units = src_tagged_code_units

  match cx.opts.string_encoding:
    case 'utf8':
      # ...
    case 'utf16':
      match src_simple_encoding:
        case 'utf8'         : return store_utf8_to_utf16(cx, src, src_code_units)
        case 'utf16'        : return store_string_copy(cx, src, src_code_units, 2, 2, 'utf-16-le')
        case 'latin1'       : return store_string_copy(cx, src, src_code_units, 2, 2, 'utf-16-le')
    case 'latin1+utf16':
      # ...

src, src_encoding, src_tagged_code_units = v を見る限り component-level string は3要素によって構成されているのか? (どこかに記述あった?) 使われ方から察するに

  • src: 実際の文字列データを表すPythonの str 型の値
  • src_encoding: 元の文字列のエンコーディングを示す文字列 ( "utf8", "utf16", "latin1+utf16")
  • src_tagged_code_units: 元の文字列の符号化された文字数を表す値

まず src_encodinglatin1+utf16 のケース、これは何?

The string-encoding option specifies the encoding the Canonical ABI will use for the string type. The latin1+utf16 encoding captures a common string encoding across Java, JavaScript and .NET VMs and allows a dynamic choice between either Latin-1 (which has a fixed 1-byte encoding, but limited Code Point range) or UTF-16 (which can express all Code Points, but uses either 2 or 4 bytes per Code Point). If no string-encoding option is specified, the default is UTF-8. It is a validation error to include more than one string-encoding option.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/Explainer.md#canonical-abi

Strings are loaded from two i32 values: a pointer (offset in linear memory) and a number of bytes. There are three supported string encodings in canonopt: UTF-8, UTF-16 and latin1+utf16. This last options allows a dynamic choice between Latin-1 and UTF-16, indicated by the high bit of the second i32. String values include their original encoding and byte length as a "hint" that enables store_string (defined below) to make better up-front allocation size choices in many cases. Thus, the value produced by load_string isn't simply a Python str, but a tuple containing a str, the original encoding and the original byte length.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#loading

if bool(src_tagged_code_units & UTF16_TAG) は文字列が実際にLatin-1でエンコードされているか、UTF-16でエンコードされているかを判別するっぽい。何をしているんだ?
UTF16_TAG = 1 << 31 なので、どうやら src_tagged_code_units の最上位bitは latin1+utf16 ではutf16を使うかどうかを示す値になっているようだ。load_string から読んだほうが良かった気がしてきた〜

次に文字列を実際にlinear memory上に配置する部分のコードを見ていく。

      match src_simple_encoding:
        case 'utf8'         : return store_utf8_to_utf16(cx, src, src_code_units)
        case 'utf16'        : return store_string_copy(cx, src, src_code_units, 2, 2, 'utf-16-le')
        case 'latin1'       : return store_string_copy(cx, src, src_code_units, 2, 2, 'utf-16-le')

とりあえず簡単のために store_string_copy から見ていく。

tanishikingtanishiking

store_string_copy

store_string_copy はソース文字列を指定されたエンコーディングでlinear memoryにコピーする関数。この関数は、ソース文字列のエンコーディングとターゲットエンコーディングが同じ場合、またはLatin-1からUTF-16への変換を行う場合に使用される。

# `cx`: call context
# `src`: コピー元の文字列(Pythonの str 型)
# `src_code_units`: ソース文字列の符号化された文字数
# `dst_code_unit_size`: ターゲットエンコーディングにおける1文字あたりのバイト数
# `dst_alignment`: ターゲットエンコーディングにおけるアライメント
# `dst_encoding`: ターゲットエンコーディング
def store_string_copy(cx, src, src_code_units, dst_code_unit_size, dst_alignment, dst_encoding):
  dst_byte_length = dst_code_unit_size * src_code_units
  trap_if(dst_byte_length > MAX_STRING_BYTE_LENGTH)
  ptr = cx.opts.realloc(0, 0, dst_alignment, dst_byte_length)
  trap_if(ptr != align_to(ptr, dst_alignment))
  trap_if(ptr + dst_byte_length > len(cx.opts.memory))
  encoded = src.encode(dst_encoding)
  assert(dst_byte_length == len(encoded))
  cx.opts.memory[ptr : ptr+len(encoded)] = encoded
  return (ptr, src_code_units)

realloc が見える。

Storing strings is complicated by the goal of attempting to optimize the different transcoding cases. In particular, one challenge is choosing the linear memory allocation size before examining the contents of the string. The reason for this constraint is that, in some settings where single-pass iterators are involved (host calls and post-MVP adapter functions), examining the contents of a string more than once would require making an engine-internal temporary copy of the whole string, which the component model specifically aims not to do. To avoid multiple passes, the canonical ABI instead uses a realloc approach to update the allocation size during the single copy. A blind realloc approach would normally suffer from multiple reallocations per string (e.g., using the standard doubling-growth strategy). However, as already shown in load_string above, string values come with two useful hints: their original encoding and byte length. From this hint data, store_string can do a much better job minimizing the number of reallocations.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#storing

realloc は以下のように定義される

The (realloc ...) option specifies a core function that is validated to have the following core function type:
(func (param $originalPtr i32)
(param $originalSize i32)
(param $alignment i32)
(param $newSize i32)
(result i32))
The Canonical ABI will use realloc both to allocate (passing 0 for the first two parameters) and reallocate. If the Canonical ABI needs realloc, validation requires this option to be present (there is no default).
https://github.com/WebAssembly/component-model/blob/main/design/mvp/Explainer.md#canonical-abi

この realloc はコンパイラもしくはbinding generatorが実装しないといけない。rustのwit-bindgenにも実装が含まれている (これかな ?)

多分 originalPtroriginalSize が0の場合はmalloc的なことをしてねという感じなんだろう。想定される挙動はCとかのrealloc と同じでいいのかな。

なので、store_string_copy の挙動は簡単で、dst_alignmentdst_byte_lengthに応じてメモリを確保し、文字列エンコーディングを変換し、確保したメモリに文字列をstore。

最終的に return (ptr, src_code_units) なんだけど、これ src_code_units を返すの? 異なるえんこーどしたら code units は変わるのでは? この関数は latin-1 -> utf-16 もしくは同じエンコードのときのみ呼び出されるからうまくいってるだけに見える

tanishikingtanishiking

store_utf8_to_utf16

UTF-8エンコーディングの文字列をUTF-16エンコーディングに変換し、linear memoryに格納する

# src_code_units はUTF-8文字列のバイト数
def store_utf8_to_utf16(cx, src, src_code_units):
  # UTF-8文字列の各バイトがUTF-16の2バイトに変換されると仮定して、最悪ケースのメモリサイズを計算
  worst_case_size = 2 * src_code_units
  trap_if(worst_case_size > MAX_STRING_BYTE_LENGTH)
  # realloc を使って計算した worst_case_size で linear memory に確保。アライメントは2バイト境界
  # realloc関数は、最初の2つの引数に0を渡すことで新規メモリ割り当てを行うのだった
  ptr = cx.opts.realloc(0, 0, 2, worst_case_size)
  trap_if(ptr != align_to(ptr, 2))
  trap_if(ptr + worst_case_size > len(cx.opts.memory))
  # 文字列を utf-16 にエンコード
  encoded = src.encode('utf-16-le')
  # linear memory に格納
  cx.opts.memory[ptr : ptr+len(encoded)] = encoded
  # メモリ領域の縮小: エンコード後のサイズが worst_case_size よりも小さい場合(だいたいそうだろ)、realloc 関数を呼び出してメモリ領域を縮小
  if len(encoded) < worst_case_size:
    ptr = cx.opts.realloc(ptr, worst_case_size, 2, len(encoded))
    trap_if(ptr != align_to(ptr, 2))
    trap_if(ptr + len(encoded) > len(cx.opts.memory))
  # 確保したメモリ領域の先頭ポインタと、UTF-16エンコーディングにおける文字数を返す
  code_units = int(len(encoded) / 2)
  return (ptr, code_units)

先に len(encoded) を計算してから realloc すれば良くない???

Storing strings is complicated by the goal of attempting to optimize the different transcoding cases. In particular, one challenge is choosing the linear memory allocation size before examining the contents of the string. The reason for this constraint is that, in some settings where single-pass iterators are involved (host calls and post-MVP adapter functions), examining the contents of a string more than once would require making an engine-internal temporary copy of the whole string, which the component model specifically aims not to do. To avoid multiple passes, the canonical ABI instead uses a realloc approach to update the allocation size during the single copy. A blind realloc approach would normally suffer from multiple reallocations per string (e.g., using the standard doubling-growth strategy). However, as already shown in load_string above, string values come with two useful hints: their original encoding and byte length. From this hint data, store_string can do a much better job minimizing the number of reallocations.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#storing

あ〜encodeするためにはengine-internal temporary copy of the whole stringを確保しないといけない。ん〜でもそれはユーザーランドでコピーするか(realloc)、VMでコピーするかの違いでは? "The reason for this constraint is that, in some settings where single-pass iterators are involved (host calls and post-MVP adapter functions)" このへんに何かあるのかもしれない?うーんよく分かりませんでした

tanishikingtanishiking

lower_flat 続き

stringのlowerから一旦離れて残りの lower_flat 見ていく

    case ListType(t, l)     : return lower_flat_list(cx, v, t, l)
    case RecordType(fields) : return lower_flat_record(cx, v, fields)
    case VariantType(cases) : return lower_flat_variant(cx, v, cases)
    case FlagsType(labels)  : return lower_flat_flags(v, labels)
    case OwnType()          : return [lower_own(cx, v, t)]
    case BorrowType()       : return [lower_borrow(cx, v, t)]

lower_flat_list

# v: 変換対象の list 型の値
# elem_type: list の要素の型
# maybe_length: list の長さ (固定長リストの場合)
def lower_flat_list(cx, v, elem_type, maybe_length):
  if maybe_length is not None:
    assert(maybe_length == len(v))
    flat = []
    for e in v:
      flat += lower_flat(cx, e, elem_type)
    return flat
  (ptr, length) = store_list_into_range(cx, v, elem_type)
  return [ptr, length]

固定長リストの場合

  • maybe_lengthNone 出ない場合
  • 各要素を lower_flat して結合したcore wasm value のリストを返す

可変長リストの場合

  • store_list_into_range で linear memory に list v を格納、先頭ポインタ ptr とリストの長さ length を返す
def store_list_into_range(cx, v, elem_type):
  # elem_size は elem_type を linear memory 上に格納する場合(?)に必要なバイト数を返してくれる関数
  # https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#element-size
  # 例えば list の場合は、固定長なら maybe_length * elem_size(elem_type) だし、そうでないなら8(ptrとlength)
  # リスト v をlinear memoryに格納するために必要なバイト数を計算
  byte_length = len(v) * elem_size(elem_type)
  trap_if(byte_length >= (1 << 32))
  # メモリを確保
  ptr = cx.opts.realloc(0, 0, alignment(elem_type), byte_length)
  trap_if(ptr != align_to(ptr, alignment(elem_type)))
  trap_if(ptr + byte_length > len(cx.opts.memory))
  # 各要素をlinear memoryに保存
  store_list_into_valid_range(cx, v, ptr, elem_type)
  return (ptr, len(v))

def store_list_into_valid_range(cx, v, ptr, elem_type):
  for i,e in enumerate(v):
    store(cx, e, elem_type, ptr + i * elem_size(elem_type))

lower_flat_record

def lower_flat_record(cx, v, fields):
  flat = []
  for f in fields:
    flat += lower_flat(cx, v[f.label], f.t)
  return flat

valueだけをぎっしりlinear memoryに格納するみたい。型情報やlabel名はload時に与えられるrecord型が知っているのでlinear memoryに保持する必要はないんですね

tanishikingtanishiking

lower_flat_variant

ネタバレを書いておくと、例えば (variant (case "a" u32) (case "b" string)) のような variant型について

  • component-level value (python) は {"a": 42}{"b": "foo"} のような1要素のみを持つ辞書配列(keyにはvariant型のcaseラベルを持ち、valueにはそのcaseの型に対応する値を持つ)
  • core wasm value は
    • 先頭に i32: case_index。例えば {"a": 42} の場合は0番目のcaseにマッチするので 0など
    • その後にvalueのlowered valueになる。例えば42(i32)はcore wasmでもi32。文字列は[i32,i32]で表現されるのだった。
    • なので、例えば[0, 42] (0番目のcaseにマッチして、値はi32型の42)や、[1, 100, 3] (1番目のcaseにマッチし、値はoffset100に格納された長さ3の文字列)など

Component-level variant のモデルを調べる / refines とは...?

variant の lower 実装を眺める前に component-level ではどういうふうにモデル化されているのかを調べてみる。そのために load_variant を一部チラ見する。

Core wasm value から variant を load するときは以下のようなコードになっており cases は多分 list[CaseType] だろう。case_label_with_refinements(c, cases)c は値の持つlabelとマッチするlabelを持つCaseType

def load_variant(cx, ptr, cases):
    # ...
    case_label = case_label_with_refinements(c, cases)
    # ...
    return { case_label: load(cx, ptr, c.t) }

@dataclass
class CaseType:
  label: str
  t: Optional[ValType]
  refines: Optional[str] = None

@dataclass
class VariantType(ValType):
  cases: list[CaseType]

case_label = case_label_with_refinements(c, cases) でcomponent-level variantのkeyに使う文字列を計算し、{ case_label: load(cx, ptr, c.t) } というように1要素のみを持つ辞書配列(valueにはloadしたcomponent-level value)を返す。なので (variant (case "a" u32) (case "b" string)) のような variant型の値として {"a": 42} などがくる

case_labelc.label ではなくてcase_label_with_refinementsという関数を使うのが気になる。refines とかいうものが c: CaseType に定義されていた場合は find_case(c.refines, cases) マッチするcaseのindexを探索し|で結合する。つまりrefinesにはcasesにあるlabelが保存されていることが期待されているようだ。

def case_label_with_refinements(c, cases):
  label = c.label
  while c.refines is not None:
    c = cases[find_case(c.refines, cases)]
    label += '|' + c.label
  return label

CaseTyperefines はなんか subtyping 関連のためのフィールドっぽい???他のフィールドを指定してそのフィールドでもマッチするようにするみたいな感じなのかな?ならlabelでマッチさせるの意味わからないし1つのフィールドしか指定できないのもよくわからない。...
このcommit で突然消えて binary レベルでは refines0x00 が求められるようになった。どういう意思決定があったのか commit message に書こうよ...

消えた部分のコメントには...

The [subtyping] between all these types is described in a separate
subtyping explainer. Of note here, though: the optional
refines field in the cases of variants is exclusively concerned with
subtyping. In particular, a variant subtype can contain a case not present
in the supertype if the subtype's case refines (directly or transitively)
some case in the supertype.
| variant | cases can be reordered; covariant case payload subtyping; superfluous cases can be ignored in the supertype; refines cases can be ignored in the subtype |

う〜ん、一旦無視無視。

lower_flat_variant

それではいよいよ lower_flat_variant を見ていく。

  • v{"a": 42} のような一要素の辞書配列
  • cases は多分 list[CaseType]
def lower_flat_variant(cx, v, cases):
  case_index, case_value = match_case(v, cases)
  flat_types = flatten_variant(cases)
  assert(flat_types.pop(0) == 'i32')
  c = cases[case_index]
  if c.t is None:
    payload = []
  else:
    payload = lower_flat(cx, case_value, c.t)
    for i,(fv,have) in enumerate(zip(payload, flatten_type(c.t))):
      want = flat_types.pop(0)
      match (have, want):
        case ('f32', 'i32') : payload[i] = encode_float_as_i32(fv)
        case ('i32', 'i64') : payload[i] = fv
        case ('f32', 'i64') : payload[i] = encode_float_as_i32(fv)
        case ('f64', 'i64') : payload[i] = encode_float_as_i64(fv)
        case _              : assert(have == want)
  for _ in flat_types:
    payload.append(0)
  return [case_index] + payload

case_index, case_value = match_case(v, cases)

まずは match_case で値がマッチするcaseのindexと、値を取得する。

例えば (variant (case "a" u32) (case "b" string)) 型の値 {"a": 42} に対しては

  • case_index は v.label と一致する cases の indexなので 0
  • case_value は v の唯一の要素の value なので 42
def match_case(v, cases):
  assert(len(v.keys()) == 1)
  key = list(v.keys())[0]
  value = list(v.values())[0]
  # refines がある場合は `|` で区切られた文字になるっぽいが、refinesはないのでここでは1要素しか来ないはず
  for label in key.split('|'):
    # v のラベルと一致する cases を探索
    # python詳しくないんだけど、これ-1が帰ってきた場合何もreturnされないんだけど、どうなるんだ?
    case_index = find_case(label, cases)
    if case_index != -1:
      return (case_index, value)

# v がマッチする cases (list[CaseType]?) のインデックスまたは、マッチしなかった場合は-1
def find_case(label, cases):
  matches = [i for i,c in enumerate(cases) if c.label == label]
  assert(len(matches) <= 1)
  if len(matches) == 1:
    return matches[0]
  # 見つからない / 複数マッチした場合(そんなことある?validationで同じラベル持つケースは弾かれてほしい...)は-1を返す
  return -1

flat_types = flatten_variant(cases)

次に flatten_variant(cases) で variant型の core wasm 型を計算する。

おさらいしておくとvariantのcore wasm value表現は

  • 先頭に i32 (case_index)
  • その後ろに value の core wasm 表現が続く。
# cases は list[CaseType]
def flatten_variant(cases):
  flat = []
  for c in cases:
    # c.t が None なことってあるんか? -> Option None や Enum
    if c.t is not None:
      # flatten_types は component-level type を core wasm type に lower する。詳しくは後で
      for i,ft in enumerate(flatten_type(c.t)):
        if i < len(flat):
          flat[i] = join(flat[i], ft)
        else:
          flat.append(ft)
  return flatten_type(discriminant_type(cases)) + flat

まずは flatten_type(discriminant_type(cases)) を見る。

  • discriminant_type(cases)cases の数に応じて、先頭のcase_indexを保持するのに最低限必要なサイズを計算する。256以下なら1バイトで十分なのでu8など。
  • そして flatten_type で component-level type を core wasm type に変換する(詳しくはまた後で)
    • ちなみに flatten_type は u8, u16, u32 全てに対して i32 を返すので、最初からi32で良いのでは?と思う。
def discriminant_type(cases):
  n = len(cases)
  assert(0 < n < (1 << 32))
  match math.ceil(math.log2(n)/8):
    case 0: return U8Type()
    case 1: return U8Type()
    case 2: return U16Type()
    case 3: return U32Type()

次に後ろに続く flat を見ていく。

  # ...
  flat = []
  for c in cases:
    # c.t が None なことってあるんか? -> Option None や Enum
    if c.t is not None:
      # flatten_types は component-level type を core wasm type に lower する。詳しくは後で
      for i,ft in enumerate(flatten_type(c.t)):
        if i < len(flat):
          flat[i] = join(flat[i], ft)
        else:
          flat.append(ft)
  # ...

何やら不思議なことをしているが、基本的には flat には case型のうち最長の core wasm value を持つものが保存される。例えば (variant (case "a" u32) (case "b" string)) については u32[i32] に lower され、string[i32, i32] に保存されるため、flat[i32, i32] となる。

そのように、型が一致するような場合はいいんだけど、一致しない場合はどうするんだろう? flat[i] = join(flat[i], ft) が肝。

join 関数は2つの core wasm type を受取り、2つの型の値を表現できる"最適な"型を返す関数。

  • 2つの型が同じならその型を返す(わかる)
  • i32 vs f32 なら i32 を返す
  • その他の場合は i64 一番大きいもんね〜 f64 のときとかはまた reinterpret する感じなのかな
def join(a, b):
  if a == b: return a
  if (a == 'i32' and b == 'f32') or (a == 'f32' and b == 'i32'): return 'i32'
  return 'i64'

assert(flat_types.pop(0) == 'i32')

これは flat_types の先頭が i32 であることを確認するだけ。そらそうだ

value を lower

次に c = cases[case_index] で value がマッチする case を取得し、型が指定されていない場合は payload なし(だって値もないもん)、ある場合は lower_flat で値を core wasm 型に落とす。

  c = cases[case_index]
  if c.t is None:
    payload = []
  else:
    payload = lower_flat(cx, case_value, c.t)

join 相当のことを値に施す

variant型をcore wasm型に変換する際、join 関数により2つの core wasm type を受取り、2つの型の値を表現できる"最適な"型に変換する処理をしていた。これと同様の変換を値に対しても実行する必要がある。

    for i,(fv,have) in enumerate(zip(payload, flatten_type(c.t))):
      want = flat_types.pop(0)
      match (have, want):
        case ('f32', 'i32') : payload[i] = encode_float_as_i32(fv)
        case ('i32', 'i64') : payload[i] = fv
        case ('f32', 'i64') : payload[i] = encode_float_as_i32(fv)
        case ('f64', 'i64') : payload[i] = encode_float_as_i64(fv)
        case _              : assert(have == want)

def encode_float_as_i32(f):
  return core_i32_reinterpret_f32(maybe_scramble_nan32(f))

def encode_float_as_i64(f):
  return core_i64_reinterpret_f64(maybe_scramble_nan64(f))

値が f32 や f64 なら encode_float_as_i32/i64 で整数型に reinterpret するだけ

0埋め

variant型のflat_typeはcasesのうち最も長いcore typeに合わせられるのであった。(variant (case "a" u32) (case "b" string)) の場合は case_index + string の [i32, i32, i32]flat_type となる。一方、値が {"a": 42} の場合は最初の2つのi32しか使わない。

余った後ろの i32 は 0で埋める

  for _ in flat_types:
    payload.append(0)

case_index と結合

そして最終的に、case_index と値のcore wasm表現とを結合して返す。

  return [case_index] + payload

長かったけどこれでやっと variant の lower が分かった...

tanishikingtanishiking

lower_flat_flags

flags は特殊化された値で (flags "<label>"*) ↦ (record (field "<label>" bool)*) のようにrecordによって表現されるのだった。

As an optimization, flags are represented as packed bit-vectors. Like variant discriminants, flags use the smallest integer that fits all the bits, falling back to sequences of i32s when there are more than 32 flags.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#alignment

とのことでcore wasm levelではpacked big vectorとして表現される。一方 component level では key には flag の label が来て、全ての value が bool な record。

例えば (flags "a" "b" "c") という flag型に対して

  • component-level value: {"a": False, "b": False, "c": True}
  • core wasm value: 0b100
    • labelの先頭から least significant bit から順に0か1を置いていく。
def lower_flat_flags(v, labels):
  assert(0 < len(labels) <= 32)
  return [pack_flags_into_int(v, labels)]

def pack_flags_into_int(v, labels):
  i = 0
  shift = 0
  for l in labels:
    i |= (int(bool(v[l])) << shift)
    shift += 1
  return i
tanishikingtanishiking

lower_own / lower_bottom

最後にown型・bottom型を見ていく。

@dataclass
class OwnType(ValType):
  rt: ResourceType

@dataclass
class BorrowType(ValType):
  rt: ResourceType

class ResourceType(Type):
  impl: ComponentInstance
  dtor: Optional[Callable]
  dtor_sync: bool
  dtor_callback: Optional[Callable]

The own and borrow value types are both handle types. Handles logically contain the opaque address of a resource and avoid copying the resource when passed across component boundaries. By way of metaphor to operating systems, handles are analogous to file descriptors, which are stored in a table and may only be used indirectly by untrusted user-mode processes via their integer index in the table.

handle っていうのは resource を指し示す file descriptor みたいなものらしい。

In the Component Model, handles are lifted-from and lowered-into i32 values that index an encapsulated per-component-instance handle table that is maintained by the canonical function definitions described below. In the future, handles could be backwards-compatibly lifted and lowered from [reference types] (via the addition of a new canonopt, as introduced below).

core-module では i32 に lower され、component-level ではこのhandle typeにliftされる。

The uniqueness and dropping conditions mentioned above are enforced at runtime by the Component Model through these canonical definitions. The typeidx immediate of a handle type must refer to a resource type (described below) that statically classifies the particular kinds of resources the handle can point to.
https://github.com/WebAssembly/component-model/blob/main/design/mvp/Explainer.md#handle-types

具体例

いまいちピンと来ていない。具体例を見ていきたい

Resource の作成

canon resource.new を使って resource を作成する。

(component
  (type $MyResource (resource (rep i32)))
  (core func $MyResource_new (param i32) (result i32) (canon resource.new $MyResource))
  ;; ...
)

(canon resource.new $rt (core func $f)) と定義されており(なんかExplainerにある表現と違うよね、(core func $f (param i32) (result i32) (canon resource.new $rt)) は abbreviation になるのかな?)

  • $rt must refer to locally-defined (not imported) resource type
  • $f は(現状では) i32 -> i32 関数 (なぜなら resource の core wasm 表現は現状 i32 のみなので)

これにより $MyResource_new 関数が定義され、実行時には以下の python 関数が実行されることになり、owning handle を component instance の ResourceTable に追加する。

async def canon_resource_new(rt, task, rep):
  trap_if(not task.inst.may_leave)
  h = ResourceHandle(rep, own=True)
  i = task.inst.resources.add(rt, h)
  return [i]

rep が parameter として受け取った i32 で、返り値はcomponent instanceのその登録したresourceを指し示すindex値

Resourceの仕様

canon resource.rep を使って resource を使用する。

(canon resource.rep $rt (core func $f))

  • $rt must refer to a locally-defined (not imported) resource type
  • $f is given type (func (param i32) (result $rt.rep)), where $rt.rep is currently fixed to be i32.

resource.new の利用例を考えるとこんな感じ? $use_my_resource は ResourceHandle index を受け取って、resource representation (i32) を返す。

(core func $use_my_resource (param $handle i32) (result i32)
  (canon resource.rep $MyResource)
)

pythonコードのモデルでは以下のコードが実行されるみたい

async def canon_resource_rep(rt, task, i):
  h = task.inst.resources.get(rt, i)
  return [h.rep]

リソースの破棄

canon resource.drop を使って破棄。破棄って何? (canon resource.drop $rt $async? (core func $f))

  • $rt は resource type (これはこの component が own する resource じゃなくていいのかな)
  • $fi32 (ResourceHandleを示す index)
(core func $drop_my_resource (param $handle i32)
  (canon resource.drop $MyResource)
)

で、これを呼び出すと何が起きるのかというと以下のpythonコード相当のものが実行される。

  • 現在のcomponent instanceのハンドルテーブルからhandleが削除。
  • handleが所有権を持つ場合、resourceのdesctoructorを呼び出し
async def canon_resource_drop(rt, sync, task, i):
  trap_if(not task.inst.may_leave)
  inst = task.inst
  # handle table から削除
  h = inst.resources.remove(rt, i)
  # 一旦 async を考えないので `[]`
  flat_results = [] if sync else [0]
  # own されている場合 destructor を呼び出し
  if h.own:
    assert(h.borrow_scope is None)
    trap_if(h.lend_count != 0)
    # リソースを定義したのと同じコンポーネントインスタンスでdropした場合
    if inst is rt.impl:
      if rt.dtor:
        await rt.dtor(h.rep)
    else:
      # 異なる component で drop した場合、何をしているんですかね?
      if rt.dtor:
        caller_opts = CanonicalOptions(sync = sync)
        callee_opts = CanonicalOptions(sync = rt.dtor_sync, callback = rt.dtor_callback)
        ft = FuncType([U32Type()],[])
        callee = partial(canon_lift, callee_opts, rt.impl, ft, rt.dtor)
        flat_results = await canon_lower(caller_opts, ft, callee, task, [h.rep])
      else:
        task.trap_if_on_the_stack(rt.impl)
  else:
    h.borrow_scope.todo -= 1
  return flat_results

なんか少し分かってきた。resource は(現状) i32 しか保持できないもので、例えば必ず開放されてほしいメモリブロックとかだと先頭アドレスとかが保持されているんだけど、開放するロジックとかはdeconstructorにあるので、なんかdeconstructorがあってこそresourceの意味がはっきりする?んだな?

deconstructor には h.rep を渡していることから dtor は i32 -> [] らしい

tanishikingtanishiking

own/borrowのresource型の例

(component
  (import "Libc" (core module $Libc ...))
  (core instance $libc (instantiate $Libc))
  ;; resource型 $R を定義、resource.drop 時に free を呼び出し
  (type $R (resource (rep i32) (dtor (func $libc "free"))))
  ;; i32 (resource rep) を受け取って i32 (lowered resource handle) を返す関数を定義
  (core func $R_new (param i32) (result i32)
    (canon resource.new $R)
  )
  ;; core module 関数 $Main を定義
  ;; R_new を import して `make_R` (R_new を呼び出すだけ) を export する
  (core module $Main
    (import "canon" "R_new" (func $R_new (param i32) (result i32)))
    (func (export "make_R") (param ...) (result i32)
      (return (call $R_new ...))
    )
  )
  ;; core module instance を作成 R_new を import して与える
  (core instance $main (instantiate $Main
    (with "canon" (instance (export "R_new" (func $R_new))))
  ))
  ;; component から `make-r` 関数を export
  ;; make_R で帰ってきた lowered resource handle を canon lift
  ;; resource を返してその ownership は 呼び出し側の component がもつ
  (export $R' "r" (type $R))
  (func (export "make-r") (param ...) (result (own $R'))
    (canon lift (core func $main "make_R"))
  )
)

じゃあ borrow型はどう作られる?

(component
  (import "fancy-fs" (instance $fancy-fs
    (export $fs "fs" (instance
      (export "file" (type (sub resource)))
      ;; ...
    ))
    (alias export $fs "file" (type $file))
    ;; こういう感じで引数のresourceに`(borrow )` を指定すると、リソースの所有権を移すのではなくリソースを借用
    (export "fancy-op" (func (param "f" (borrow $file))))
  ))
)
tanishikingtanishiking

lower_own / lower_borrow

Resource型がどういうものかなんとなく分かってきたので、lower_own/borrowを見ていく。

lower_borrowlower_own は、それぞれ borrow ハンドルと own ハンドルを現在のコンポーネントインスタンスのハンドルテーブルに新しいハンドル要素を初期化しつつ、lowerする関数。

def lower_own(cx, rep, t):
  h = ResourceHandle(rep, own=True)
  return cx.inst.resources.add(t.rt, h)

ResourceHandle オブジェクトを作成し、それを現在のコンポーネントインスタンスのリソーステーブルに追加し、resources.add はリソーステーブル上のindexを返す。

def lower_borrow(cx, rep, t):
  assert(isinstance(cx, Task))
  if cx.inst is t.rt.impl:
    return rep
  h = ResourceHandle(rep, own=False, scope=cx)
  cx.need_to_drop += 1
  return cx.inst.resources.add(t.rt, h)
  • lower_borrowResourceHandle オブジェクトを作成するのだが、own フラグは False に設定。
  • また、lower_borrow は、ResourceHandle オブジェクトの scope フィールドを現在のタスクに設定。borrow ハンドルの有効期間が、それを借用した own ハンドルの有効期間を超えないようにするため(これは具体的にはどう使われるのだろう?)
  • if cx.inst is t.rt.impl では、借borrow handle がリソース型を実装したコンポーネントに渡されると、borrow handle で使用できるのは resource.rep を呼び出すことだけであるため、lower_borrow 関数は単にリソース表現を返し、ResourceHandle を作らない(?)
    • 返ってくるのが rep なのか、ResourceHandle の index なのかが実行時に変わるの嫌すぎるくない?
  • cx.need_to_drop += 1 。これは canon_resource_drop によって decrement される。すべてのborrow handleがタスクの終了前に削除されるようにするため

このまま lift_borrow と lift_own も見ておくと都合が良い(どのように ResourceTable や scope が使われるのかわかるため)

lift_own / lift_borrow

def lift_own(cx, i, t):
  h = cx.inst.resources.remove(t.rt, i)
  trap_if(h.lend_count != 0)
  trap_if(not h.own)
  return h.rep
  • 現在のコンポーネントインスタンスのハンドルテーブルからハンドルを削除
    • これでこのcomponent instanceから所有権を放棄して、lowering component (この resource を lower する component instance)に所有権を移す
    • 実際所有権を放棄するために lend_count が 0 であることをチェックしているね
    • インデックスが実際には borrow であった場合 (not h.own)、または own ハンドルが現在 borrow として貸し出されている場合(lend_count != 0)に失敗
      • lend_count は下の lift_borrow によって increment される。(borrow $handle) を引数とする関数に own resource を与えたときとかに起きる?
      • "インデックスが実際には borrow であった場合" は実際はおこららないはず?
def lift_borrow(cx, i, t):
  assert(isinstance(cx, Subtask))
  h = cx.inst.resources.get(t.rt, i)
  if h.own:
    cx.add_lender(h)
  else:
    trap_if(cx.task is not h.scope)
  return h.rep

def add_lender(self, lending_handle):
  assert(lending_handle.own)
  lending_handle.lend_count += 1
  self.lenders.append(lending_handle)
  • リソーステーブルから representation を読み取り (lift_own と違って削除しない)
  • 受け取ったハンドルが own ハンドルの場合 add_lender によって lend_count が increment される。
  • own ではなく borrow ハンドルの場合は、その borrow handle の scope が、このタスク(関数)と同一でないといけない。(この scope は lower_borrow で設定されるのだった)

なんか多分VM実装しないとイマイチぴんとこないなぁ

tanishikingtanishiking

lift_flat_values

ここまでで lower_flat_values を見てきたので、次はやっとこさ、lift_flat_values による変換を見ていく。

def lift_flat_values(cx, max_flat, vi, ts):
  flat_types = flatten_types(ts)
  if len(flat_types) > max_flat:
    return lift_heap_values(cx, vi, ts)
  else:
    return [ lift_flat(cx, vi, t) for t in ts ]
  • lower_flat_values と同様に、max_flat を超えた場合は lift_heap_values ですべてを linear memory を介してやりとりし、そうでない場合は各値を lift_flat する
  • max_flatreturn_on_start にあるが、引数をliftする場合は16、返り値は1(今のところ)

The number of flattened results is currently limited to 1 due to various parts of the toolchain (notably the C ABI) not yet being able to express multi-value returns

C ABI との関係性は???

ところで、この viCoreValueIterator という、next メソッドを持っているiterator。このnext にcomponent-levelターゲット型を与えることで必要な数だけcore wasm valueを読み取ってくれる。便利だね。これを使ってcore wasm valueを読んでいくよ。

@dataclass
class CoreValueIter:
  values: list[int|float]
  i = 0
  def next(self, t):
    v = self.values[self.i]
    self.i += 1
    match t:
      case 'i32': assert(isinstance(v, int) and 0 <= v < 2**32)
      case 'i64': assert(isinstance(v, int) and 0 <= v < 2**64)
      case 'f32': assert(isinstance(v, (int,float)))
      case 'f64': assert(isinstance(v, (int,float)))
      case _    : assert(False)
    return v

lift_flat

lift_flatlower_flat の反対をするだけ

def lift_flat(cx, vi, t):
  match despecialize(t):
    case BoolType()         : return convert_int_to_bool(vi.next('i32'))
    case U8Type()           : return lift_flat_unsigned(vi, 32, 8)
    case U16Type()          : return lift_flat_unsigned(vi, 32, 16)
    case U32Type()          : return lift_flat_unsigned(vi, 32, 32)
    case U64Type()          : return lift_flat_unsigned(vi, 64, 64)
    case S8Type()           : return lift_flat_signed(vi, 32, 8)
    case S16Type()          : return lift_flat_signed(vi, 32, 16)
    case S32Type()          : return lift_flat_signed(vi, 32, 32)
    case S64Type()          : return lift_flat_signed(vi, 64, 64)
    case F32Type()          : return canonicalize_nan32(vi.next('f32'))
    case F64Type()          : return canonicalize_nan64(vi.next('f64'))
    case CharType()         : return convert_i32_to_char(cx, vi.next('i32'))
    case StringType()       : return lift_flat_string(cx, vi)
    case ListType(t, l)     : return lift_flat_list(cx, vi, t, l)
    case RecordType(fields) : return lift_flat_record(cx, vi, fields)
    case VariantType(cases) : return lift_flat_variant(cx, vi, cases)
    case FlagsType(labels)  : return lift_flat_flags(vi, labels)
    case OwnType()          : return lift_own(cx, vi.next('i32'), t)
    case BorrowType()       : return lift_borrow(cx, vi.next('i32'), t)

lift_flat_unsigned / lift_flat_signed

u8-u64 と i8-i64 は core wasm では i32 or i64 で表現されるのだった。lift_flat_(un)signed は i32/i64 の値を受取、ターゲット型(component level)の幅に合わない上位bitは無視する。

  • lift_flat_unsigned は モジュロ計算により上位ビットを無視
  • lift_flat_signed はまず lift_flat_unsigned と同じ方法で値をリフト(モジュロ計算)。次に、値がターゲット型の符号付き範囲の上限よりも大きい場合、値からターゲット型の幅を引く。これにより、値がターゲット型の有効な符号付き範囲内にあることが保証される。
def lift_flat_unsigned(vi, core_width, t_width):
  i = vi.next('i' + str(core_width))
  assert(0 <= i < (1 << core_width))
  return i % (1 << t_width)

def lift_flat_signed(vi, core_width, t_width):
  i = vi.next('i' + str(core_width))
  assert(0 <= i < (1 << core_width))
  i %= (1 << t_width)
  if i >= (1 << (t_width - 1)):
    return i - (1 << t_width)
  return i

canonicalize_nan32/64

float型はNaNに対する処理がいくつかあるものの、NaNでない場合はcomponent-level valueとcore-wasm valueは同じものを使う。 canonicalize_nan32/64 は NaNを処理するだけ。

def canonicalize_nan32(f):
  if math.isnan(f):
    f = core_f32_reinterpret_i32(CANONICAL_FLOAT32_NAN)
    assert(math.isnan(f))
  return f

convert_i32_to_char

def convert_i32_to_char(cx, i):
  assert(i >= 0)
  trap_if(i >= 0x110000)
  trap_if(0xD800 <= i <= 0xDFFF)
  return chr(i)

i32 -> char。core wasm i32 が Unicode Scalar Value であること・サロゲートではないことをチェックして、pythonのcharに変換。

lift_flat_string

string は 2つの i32 (linear memory 上のoffset + code unit length) で構成されていた。これら2つの値を読み取って、load_string_from_range で component-level string に変換。

def lift_flat_string(cx, vi):
  ptr = vi.next('i32')
  packed_length = vi.next('i32')
  return load_string_from_range(cx, ptr, packed_length)

def load_string_from_range(cx, ptr, tagged_code_units):
  # optionで指定されている string_encoding から alignment・tagged_code_unitsからbyte length、encoding を計算
  match cx.opts.string_encoding:
    case 'utf8':
      # ...
    case 'utf16':
      alignment = 2
      byte_length = 2 * tagged_code_units
      encoding = 'utf-16-le'
    case 'latin1+utf16':
      # ...

  trap_if(ptr != align_to(ptr, alignment))
  trap_if(ptr + byte_length > len(cx.opts.memory))
  try:
    # linear memory からバイト列を読み込みpython stringにencode
    s = cx.opts.memory[ptr : ptr+byte_length].decode(encoding)
  except UnicodeError:
    trap()

  return (s, cx.opts.string_encoding, tagged_code_units)

最終的に3つ組(component-level string)を返す。

  • src: 実際の文字列データを表すPythonの str 型の値
  • src_encoding: 文字列のエンコーディングを示す文字列 ( "utf8", "utf16", "latin1+utf16")
  • src_tagged_code_units: 文字列の符号化された文字数を表す値

lift_flat_list

list は固定長と可変長かによってcore wasmでの表現の仕方が違うのだった

  • 固定長リストの場合
    • maybe_length が None 出ない場合
    • 各要素を lower_flat して結合したcore wasm value のリスト
  • 可変長リストの場合
    • store_list_into_range で linear memory に list v を格納、先頭ポインタ ptr とリストの長さ length

これに応じて encode するだけ。

def lift_flat_list(cx, vi, elem_type, maybe_length):
  # 固定長リスト
  if maybe_length is not None:
    a = []
    for i in range(maybe_length):
      a.append(lift_flat(cx, vi, elem_type))
    return a
  # 可変長リスト
  ptr = vi.next('i32')
  length = vi.next('i32')
  return load_list_from_range(cx, ptr, length, elem_type)

def load_list_from_range(cx, ptr, length, elem_type):
  trap_if(ptr != align_to(ptr, alignment(elem_type)))
  trap_if(ptr + length * elem_size(elem_type) > len(cx.opts.memory))
  return load_list_from_valid_range(cx, ptr, length, elem_type)

# elem_size は elem_type を linear memory 上に格納する場合(?)に必要なバイト数を返してくれる関数
# https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md#element-size
# 例えば list の場合は、固定長なら maybe_length * elem_size(elem_type) だし、そうでないなら8(ptrとlength)
# リスト v をlinear memoryに格納するために必要なバイト数を計算
def load_list_from_valid_range(cx, ptr, length, elem_type):
  a = []
  for i in range(length):
    a.append(load(cx, ptr + i * elem_size(elem_type), elem_type))
  return a

lift_flat_record

record は core wasm value では value の値だけのリストで、component level では python の辞書配列。

fields によって値のターゲット型やラベル名が与えられるので、値(ValueIterator)を読み込みながら辞書配列を構築していく。

def lift_flat_record(cx, vi, fields):
  record = {}
  for f in fields:
    record[f.label] = lift_flat(cx, vi, f.t)
  return record

lift_flat_variant

おさらい (variant (case "a" u32) (case "b" string)) のような variant型について

  • component-level value (python) は {"a": 42} や {"b": "foo"} のような1要素のみを持つ辞書配列(keyにはvariant型のcaseラベルを持ち、valueにはそのcaseの型に対応する値を持つ)
  • core wasm value は
    • 先頭に i32: case_index。例えば {"a": 42} の場合は0番目のcaseにマッチするので 0など
    • その後にvalueのlowered valueになる。例えば42(i32)はcore wasmでもi32。文字列は[i32,i32]で表現されるのだった。
    • なので、例えば[0, 42] (0番目のcaseにマッチして、値はi32型の42)や、[1, 100, 3] (1番目のcaseにマッチし、値はoffset100に格納された長さ3の文字列)など
def lift_flat_variant(cx, vi, cases):
  # core wasm values から適切な数だけ値を読み込むために、variant の flat 型を調べる
  flat_types = flatten_variant(cases)
  # 1要素目は case_index (i32) なので無視
  assert(flat_types.pop(0) == 'i32')
  case_index = vi.next('i32')
  trap_if(case_index >= len(cases))
  # variant では f32 や f64 なども i32 で保存するのであったので、それを decode して元の値に戻すやつ
  class CoerceValueIter:
    def next(self, want):
      have = flat_types.pop(0)
      x = vi.next(have)
      match (have, want):
        case ('i32', 'f32') : return decode_i32_as_float(x)
        case ('i64', 'i32') : return wrap_i64_to_i32(x)
        case ('i64', 'f32') : return decode_i32_as_float(wrap_i64_to_i32(x))
        case ('i64', 'f64') : return decode_i64_as_float(x)
        case _              : assert(have == want); return x
  c = cases[case_index]
  if c.t is None:
    v = None
  else:
    v = lift_flat(cx, CoerceValueIter(), c.t)
  # flat_types は variant のうち最長のケースとなる型を持つ。実際の値はもっと短い core wasm values しか持たない場合もある。その場合は余りは0埋めされているので残りは読み捨てる。
  for have in flat_types:
    _ = vi.next(have)
  # case_label_with_refinements で `refines` を読みながら label を作るのだけど、現状 `refines` は何もないので結局 `c.label` が返ってくるはず
  return { case_label_with_refinements(c, cases): v }

def wrap_i64_to_i32(i):
  assert(0 <= i < (1 << 64))
  return i % (1 << 32)

lift_flat_flags

おさらい: core wasm levelではpacked big vectorとして表現される。一方 component level では key には flag の label が来て、全ての value が bool な record。

例えば (flags "a" "b" "c") という flag型に対して

  • component-level value: {"a": False, "b": False, "c": True}
  • core wasm value: 0b100
    labelの先頭から least significant bit から順に0か1を置いていく。

この変換を書くだけ

def lift_flat_flags(vi, labels):
  assert(0 < len(labels) <= 32)
  i = vi.next('i32')
  return unpack_flags_from_int(i, labels)

def unpack_flags_from_int(i, labels):
  record = {}
  for l in labels:
    record[l] = bool(i & 1)
    i >>= 1
  return record

lift_own/borrowはさっき見たね

tanishikingtanishiking

(lower|lift)_heap_values

ここまででは flat_types が十分少ない場合について見てきた。その場合は lower_flat_values などはできる限りスタックを通して値をやり取りしていた。しかし、引数が多すぎる場合は(VMの実装時にレジスタに乗り切らないためなど?)の理由で全てlinear memoryを通したやり取りに切り替わる

lower_heap_values

def lower_heap_values(cx, vs, ts, out_param):
  tuple_type = TupleType(ts)
  tuple_value = {str(i): v for i,v in enumerate(vs)}
  # out_params が None な場合(arguments を lower する場合)
  # realloc を呼び出して、タプル値を格納するのに十分な大きさのメモリを割り当て
  # 割り当てられたメモリの先頭アドレスを ptr に格納
  # ptr を含むリストを flat_vals に格納 (これは最終的な返り値)
  if out_param is None:
    ptr = cx.opts.realloc(0, 0, alignment(tuple_type), elem_size(tuple_type))
    flat_vals = [ptr]
  else:
    # (resut型を変換する場合) out_param から i32 値を取得し、ptr に格納。
    # 空のリストを flat_vals に格納。
    ptr = out_param.next('i32')
    flat_vals = []
  trap_if(ptr != align_to(ptr, alignment(tuple_type)))
  trap_if(ptr + elem_size(tuple_type) > len(cx.opts.memory))
  # tuple values をメモリに store する
  store(cx, tuple_value, tuple_type, ptr)
  return flat_vals

argumets を lower する場合は realloc で十分な大きさのメモリを確保し、その先頭アドレスを返している

realloc は次のようなsignatureを持つ関数で oritinalPtr と originalSize が 0の場合は新しく allocate するのであった。 (func (param $originalPtr i32) (param $originalSize i32) (param $alignment i32) (param $newSize i32) (result i32))

alignementelem_size を見ていく。(lower_heap_values では値をtuple(レコードにdespecializeされる)にラップしてそれぞれを計算する)

alignment

ここまででcomponent-valuesがどのようにcore wasm valueで表現されるか見てきたので、割と直感的に理解できそう。

def alignment(t):
  match despecialize(t):
    case BoolType()                  : return 1
    case S8Type() | U8Type()         : return 1
    case S16Type() | U16Type()       : return 2
    case S32Type() | U32Type()       : return 4
    case S64Type() | U64Type()       : return 8
    case F32Type()                   : return 4
    case F64Type()                   : return 8
    case CharType()                  : return 4
    case StringType()                : return 4
    case ListType(t, l)              : return alignment_list(t, l)
    case RecordType(fields)          : return alignment_record(fields)
    case VariantType(cases)          : return alignment_variant(cases)
    case FlagsType(labels)           : return alignment_flags(labels)
    case OwnType() | BorrowType()    : return 4 # repはi32のみなので4

def despecialize(t):
  match t:
    case TupleType(ts)       : return RecordType([ FieldType(str(i), t) for i,t in enumerate(ts) ])
    case EnumType(labels)    : return VariantType([ CaseType(l, None) for l in labels ])
    case OptionType(t)       : return VariantType([ CaseType("none", None), CaseType("some", t) ])
    case ResultType(ok, err) : return VariantType([ CaseType("ok", ok), CaseType("error", err) ])
    case _                   : return t
  • BoolからCharまでは特に何も言うことはない
  • Stringは2つのi32によって構成されるので4

alignment_list

  • 固定長配列の場合は要素の型のalignment
  • 可変長の場合はoffsetと長さ(i32,i32)で表現されるので4
def alignment_list(elem_type, maybe_length):
  if maybe_length is not None:
    return alignment(elem_type)
  return 4

alignment_record

def alignment_record(fields):
  a = 1
  for f in fields:
    a = max(a, alignment(f.t))
  return a

recordに含まれる型のうち最大アラインメント。(elem_sizeとか見ていくとわかるけど、別に各フィールドに対してpaddingが入るわけではないぞ)

alignment_variant

以下2つの最大値

  • ケースの識別子 (discriminant) の型
  • ケースのペイロードの型の配置の最大値

discriminant_type は、バリアントのケース数に基づいて、ケースの識別子に使用する最小の整数型を選択

  • ケース数が 256 未満の場合、U8Type()
  • ケース数が 256 以上 65536 未満の場合、U16Type()
  • ケース数が 65536 以上の場合、U32Type()
def alignment_variant(cases):
  return max(alignment(discriminant_type(cases)), max_case_alignment(cases))

def discriminant_type(cases):
  n = len(cases)
  assert(0 < n < (1 << 32))
  match math.ceil(math.log2(n)/8):
    case 0: return U8Type()
    case 1: return U8Type()
    case 2: return U16Type()
    case 3: return U32Type()

def max_case_alignment(cases):
  a = 1
  for c in cases:
    if c.t is not None:
      a = max(a, alignment(c.t))
  return a

alignment_flags

パックされたビットベクターとして表現。alignment_flags はすべてのビットに収まる最小の整数を使用し、32以上の場合はi32のsequenceにfallbck

def alignment_flags(labels):
  n = len(labels)
  assert(0 < n <= 32)
  if n <= 8: return 1 #8bit以下なので1
  if n <= 16: return 2 # 16bit以下なので2
  return 4 # 17-32の場合は4bitで収まる

, falling back to sequences of i32s when there are more than 32 flags.

こういう記述あるけど 32 flagsより多くあることってないのでは?

elem_size

次にelem_sizeを見ていく。 StringType までは特に問題ない。

def elem_size(t):
  match despecialize(t):
    case BoolType()                  : return 1
    case S8Type() | U8Type()         : return 1
    case S16Type() | U16Type()       : return 2
    case S32Type() | U32Type()       : return 4
    case S64Type() | U64Type()       : return 8
    case F32Type()                   : return 4
    case F64Type()                   : return 8
    case CharType()                  : return 4
    case StringType()                : return 8 # i32 2つで表現されるため
    case ListType(t, l)              : return elem_size_list(t, l)
    case RecordType(fields)          : return elem_size_record(fields)
    case VariantType(cases)          : return elem_size_variant(cases)
    case FlagsType(labels)           : return elem_size_flags(labels)
    case OwnType() | BorrowType()    : return 4 # i32 representation

# 固定長の場合は length * 要素型のelem_size
# 可変長の場合は offset + length の i32 * 2 で 8
def elem_size_list(elem_type, maybe_length):
  if maybe_length is not None:
    return maybe_length * elem_size(elem_type)
  return 8

elem_size_record

def elem_size_record(fields):
  s = 0
  for f in fields:
    s = align_to(s, alignment(f.t))
    s += elem_size(f.t)
  assert(s > 0)
  return align_to(s, alignment_record(fields))

def align_to(ptr, alignment):
  return math.ceil(ptr / alignment) * alignment

recordの各要素について、N-byteの要素はN-byte alignmetに配置したいという話があり、少し複雑になっている。

recordの各要素について elem_size_recod の処理は、各フィールドについて

  • s (offset)を align_to により、そのフィールドの型のalignbmentに揃える。
  • s にフィールドの型の要素サイズを加算。
    そして最後に、sをレコード全体のalignmentに揃える。これによりこのrecordの後続のデータ構造が正しくalignされていることをensureする。

これだけ見てもいまいちピンとこない。例えば (record (field "a" u32) (field "b" u8) (field "c" u16) (field "d" u8)) の elem_size について考える。

  • u32: alilgnment 4、要素サイズ 4
  • u8: algnment 1、要素サイズ 1
  • u16: alignment 2、要素サイズ 2
  • record全体: alignment 4 (最も大きいフィールドのalignmentと同じ)、要素サイズ12 (後述)

このことから

  • a (s = 0)
    • align_to(0, 4) = 0 なのですでに alignment に offset が来ている
    • s += 4
  • b (s = 4)
    • align_to(4, 1) = 4
    • s += 1
  • c (s = 5)
    • align_to(5, 2) = 6 2バイト要素は2バイトalignmentに配置されてほしいので、align_toにより6バイト目にalignされる
    • s += 2
  • d (s = 8)
    • align_to(8, 1) = 8
    • s += 1
  • 最後に align_to(s, alignment_record(fields))
    • align_to(9, 4) = 12

よって最終的には以下のように配置されるはず

byte 0 1 2 3 4 5 6 7 8 9 10 11
field a a a a b - c c d - - -

そして record の elem_size は 12

elem_size_variant

variantは先頭にcase_index(discriminant_typeによって計算される型で保持されるunsigned int)を持ち、それに続いて値がくる(この値を保持できるようにするために、casesのうち最長となる型のリストを利用する)。

def elem_size_variant(cases):
  # 識別子のサイズ
  s = elem_size(discriminant_type(cases))
  # cases のうち最大のサイズを持つcase分だけ要素を確保する。
  # align_to により、s をペイロードの最大alignmentに調整
  s = align_to(s, max_case_alignment(cases))
  # 各ケースのペイロードの要素サイズを調べ、最大値 cs を計算し、sに追加
  cs = 0
  for c in cases:
    if c.t is not None:
      cs = max(cs, elem_size(c.t))
  s += cs
  # 最後に計算された要素サイズをバリアント型の配置に調整
  return align_to(s, alignment_variant(cases))

例えば以下のvariantの要素サイズを考える

(variant
  (case "a" (record (field "a" u32) (field "b" u8) (field "c" u16)))
  (case "b" u64)
  (case "c"))
  • 識別子のサイズ: 3つのケースをもつため discriminant_typeU8Type を返し、そのelem_sizeは1
  • ペイロードのalignment調整
    • これらのcasesの最大alignmentは8(u64)(record型は4)
    • align_to(1, 8) = 8
  • ペイロードの最大サイズを追加 (ここでは8(u64もrecordも8)) = 16
  • 最後にvarant型のalignmentによって調整。
    • variant型のalignmentは8
    • aligt_to(16, 8) = 16 なので結果は変わらず

よってこのvariant型の要素サイズは16

elem_size_flags

def elem_size_flags(labels):
  n = len(labels)
  assert(0 < n <= 32)
  if n <= 8: return 1
  if n <= 16: return 2
  return 4

flags は bit vector で表現される。17-32flagsの場合はi32を使ったbitvectorになるので4


alignmentとelem_sizeでかなり脱線してしまった。次は store を見るぞ

tanishikingtanishiking

lower_heap_valus に話を戻す。以下のような定義だった

def lower_heap_values(cx, vs, ts, out_param):
  tuple_type = TupleType(ts)
  tuple_value = {str(i): v for i,v in enumerate(vs)}
  if out_param is None:
    ptr = cx.opts.realloc(0, 0, alignment(tuple_type), elem_size(tuple_type))
    flat_vals = [ptr]
  else:
    ptr = out_param.next('i32')
    flat_vals = []
  trap_if(ptr != align_to(ptr, alignment(tuple_type)))
  trap_if(ptr + elem_size(tuple_type) > len(cx.opts.memory))
  store(cx, tuple_value, tuple_type, ptr)
  return flat_vals

ここまでで、realloc などを使って ptr の値を計算できたので、次に store を見ていく。

store

def store(cx, v, t, ptr):
  assert(ptr == align_to(ptr, alignment(t)))
  assert(ptr + elem_size(t) <= len(cx.opts.memory))
  match despecialize(t):
    case BoolType()         : store_int(cx, int(bool(v)), ptr, 1)
    case U8Type()           : store_int(cx, v, ptr, 1)
    case U16Type()          : store_int(cx, v, ptr, 2)
    case U32Type()          : store_int(cx, v, ptr, 4)
    case U64Type()          : store_int(cx, v, ptr, 8)
    case S8Type()           : store_int(cx, v, ptr, 1, signed=True)
    case S16Type()          : store_int(cx, v, ptr, 2, signed=True)
    case S32Type()          : store_int(cx, v, ptr, 4, signed=True)
    case S64Type()          : store_int(cx, v, ptr, 8, signed=True)
    case F32Type()          : store_int(cx, encode_float_as_i32(v), ptr, 4)
    case F64Type()          : store_int(cx, encode_float_as_i64(v), ptr, 8)
    case CharType()         : store_int(cx, char_to_i32(v), ptr, 4)
    case StringType()       : store_string(cx, v, ptr)
    case ListType(t, l)     : store_list(cx, v, ptr, t, l)
    case RecordType(fields) : store_record(cx, v, ptr, fields)
    case VariantType(cases) : store_variant(cx, v, ptr, cases)
    case FlagsType(labels)  : store_flags(cx, v, ptr, labels)
    case OwnType()          : store_int(cx, lower_own(cx, v, t), ptr, 4)
    case BorrowType()       : store_int(cx, lower_borrow(cx, v, t), ptr, 4)

store_int

def store_int(cx, v, ptr, nbytes, signed = False):
  cx.opts.memory[ptr : ptr+nbytes] = int.to_bytes(v, nbytes, 'little', signed=signed)

特に疑問の余地はなさそう

encode_float_as_i32

f32 の store は store_int(cx, encode_float_as_i32(v), ptr, 4)

def encode_float_as_i32(f):
  return core_i32_reinterpret_f32(maybe_scramble_nan32(f))

def core_i32_reinterpret_f32(f):
  return struct.unpack('<I', struct.pack('<f', f))[0] # i32.reinterpret_f32

def maybe_scramble_nan32(f):
  if math.isnan(f):
    if DETERMINISTIC_PROFILE:
      f = core_f32_reinterpret_i32(CANONICAL_FLOAT32_NAN)
    else:
      f = core_f32_reinterpret_i32(random_nan_bits(32, 8))
    assert(math.isnan(f))
  return f

NaNに対する処理を除くと i32.reinterpretf32 で i32 に変換した浮動小数点数をメモリに格納

store_string

def store_string(cx, v, ptr):
  begin, tagged_code_units = store_string_into_range(cx, v)
  store_int(cx, begin, ptr, 4)
  store_int(cx, tagged_code_units, ptr + 4, 4)

store_string_into_rangelower_flat_string で見た。reallocでallocateしたメモリに文字列を保存して、そのbegin(開始位置)とcode units を返す。

store_stringはbeginとtagged_code_unitsを返す。

store_list

store_list は

  • 固定長リストの場合
    • store_list_into_valid_range で各要素をメモリ上に保存していく
  • 可変長リストの場合は string と同様に list を realloc したメモリ領域に保存し、その begin と length を store
def store_list(cx, v, ptr, elem_type, maybe_length):
  if maybe_length is not None:
    assert(maybe_length == len(v))
    store_list_into_valid_range(cx, v, ptr, elem_type)
    return
  begin, length = store_list_into_range(cx, v, elem_type)
  store_int(cx, begin, ptr, 4)
  store_int(cx, length, ptr + 4, 4)

def store_list_into_valid_range(cx, v, ptr, elem_type):
  for i,e in enumerate(v):
    store(cx, e, elem_type, ptr + i * elem_size(elem_type))

store_record

def store_record(cx, v, ptr, fields):
  for f in fields:
    ptr = align_to(ptr, alignment(f.t))
    store(cx, v[f.label], f.t, ptr)
    ptr += elem_size(f.t)

elem_size で書いたような方法で record の値を保存していく。

store_variant

def store_variant(cx, v, ptr, cases):
  case_index, case_value = match_case(v, cases)
  # case_index (disc_sizeだけの幅をもつ) をメモリに格納
  disc_size = elem_size(discriminant_type(cases))
  store_int(cx, case_index, ptr, disc_size)
  ptr += disc_size
  # variant の payload の alignment を調整。
  ptr = align_to(ptr, max_case_alignment(cases))
  # payload の格納
  c = cases[case_index]
  if c.t is not None:
    store(cx, case_value, c.t, ptr)

# vがマッチするcaseのindexと、値を取得する。`|` のあたりはrefines、無視
def match_case(v, cases):
  assert(len(v.keys()) == 1)
  key = list(v.keys())[0]
  value = list(v.values())[0]
  for label in key.split('|'):
    case_index = find_case(label, cases)
    if case_index != -1:
      return (case_index, value)

store_flags

def store_flags(cx, v, ptr, labels):
  i = pack_flags_into_int(v, labels)
  store_int(cx, i, ptr, elem_size_flags(labels))

def pack_flags_into_int(v, labels):
  i = 0
  shift = 0
  for l in labels:
    i |= (int(bool(v[l])) << shift)
    shift += 1
  return i

pack_flags_into_int で flags を bitvector に変換し、それを store_int で格納


own と borrow はそれぞれ lower して出てきた handle index を store_int

tanishikingtanishiking

lift_heap_values

def lift_heap_values(cx, vi, ts):
  ptr = vi.next('i32')
  tuple_type = TupleType(ts)
  trap_if(ptr != align_to(ptr, alignment(tuple_type)))
  trap_if(ptr + elem_size(tuple_type) > len(cx.opts.memory))
  return list(load(cx, ptr, tuple_type).values())

lift_heap_values は、lower_heap_values によってstoreされたTuple値のptrを受取、それをloadによってメモリから読み取り返す。

load

store と逆のことをするだけなのでサクサク読んでいける。

def load(cx, ptr, t):
  assert(ptr == align_to(ptr, alignment(t)))
  assert(ptr + elem_size(t) <= len(cx.opts.memory))
  match despecialize(t):
    case BoolType()         : return convert_int_to_bool(load_int(cx, ptr, 1))
    case U8Type()           : return load_int(cx, ptr, 1)
    case U16Type()          : return load_int(cx, ptr, 2)
    case U32Type()          : return load_int(cx, ptr, 4)
    case U64Type()          : return load_int(cx, ptr, 8)
    case S8Type()           : return load_int(cx, ptr, 1, signed=True)
    case S16Type()          : return load_int(cx, ptr, 2, signed=True)
    case S32Type()          : return load_int(cx, ptr, 4, signed=True)
    case S64Type()          : return load_int(cx, ptr, 8, signed=True)
    case F32Type()          : return decode_i32_as_float(load_int(cx, ptr, 4))
    case F64Type()          : return decode_i64_as_float(load_int(cx, ptr, 8))
    case CharType()         : return convert_i32_to_char(cx, load_int(cx, ptr, 4))
    case StringType()       : return load_string(cx, ptr)
    case ListType(t, l)     : return load_list(cx, ptr, t, l)
    case RecordType(fields) : return load_record(cx, ptr, fields)
    case VariantType(cases) : return load_variant(cx, ptr, cases)
    case FlagsType(labels)  : return load_flags(cx, ptr, labels)
    case OwnType()          : return lift_own(cx, load_int(cx, ptr, 4), t)
    case BorrowType()       : return lift_borrow(cx, load_int(cx, ptr, 4), t)

load_int

def load_int(cx, ptr, nbytes, signed = False):
  return int.from_bytes(cx.opts.memory[ptr : ptr+nbytes], 'little', signed=signed)

特に疑問の余地なし

float

浮動小数点はメモリ上ではi32として保存されている。f32.reinterpreti32 で f32 として解釈する。

def decode_i32_as_float(i):
  return canonicalize_nan32(core_f32_reinterpret_i32(i))

def decode_i64_as_float(i):
  return canonicalize_nan64(core_f64_reinterpret_i64(i))

load_string

def load_string(cx, ptr):
  begin = load_int(cx, ptr, 4)
  tagged_code_units = load_int(cx, ptr + 4, 4)
  return load_string_from_range(cx, begin, tagged_code_units)

store_string で、reallocによって新しく確保したメモリ領域に文字列を保存し、そのbeginとcode unitsがメモリ上に置かれている。それらのi32をloadしてload_string_from_range で読み出す(lift_flat_stringで見た)

load_list

def load_list(cx, ptr, elem_type, maybe_length):
  if maybe_length is not None:
    return load_list_from_valid_range(cx, ptr, maybe_length, elem_type)
  begin = load_int(cx, ptr, 4)
  length = load_int(cx, ptr + 4, 4)
  return load_list_from_range(cx, begin, length, elem_type)

def load_list_from_valid_range(cx, ptr, length, elem_type):
  a = []
  for i in range(length):
    a.append(load(cx, ptr + i * elem_size(elem_type), elem_type))
  return a
  • 固定長リストなら load_list_from_valid_range でそこにstoreされている要素を読み取り
  • 可変長リストなら string と同様に begin と length を読み取って、その範囲から load_list_from_range で読み取り

load_record

elem_size で書いたような保存のされ方をしているので、field ごとに align_to で要素の型によってalignmentの調整をし、そこから load で値を読み取り、record 辞書配列に保存。elem_size だけずらして繰り返し

def load_record(cx, ptr, fields):
  record = {}
  for field in fields:
    ptr = align_to(ptr, alignment(field.t))
    record[field.label] = load(cx, ptr, field.t)
    ptr += elem_size(field.t)
  return record

load_variant

def load_variant(cx, ptr, cases):
  # case_index のサイズを計算し、`load_int` で読み取り
  disc_size = elem_size(discriminant_type(cases))
  case_index = load_int(cx, ptr, disc_size)
  ptr += disc_size
  trap_if(case_index >= len(cases))
  # case_index から読み取る対象の値の型などを取得
  c = cases[case_index]
  # 読み取り前に variant の最大alignmentでptr位置を調整
  ptr = align_to(ptr, max_case_alignment(cases))
  # refinements はないので `c.label` が返ってくるだけ
  case_label = case_label_with_refinements(c, cases)
  # `load` で値を読み取り、一要素のみの辞書配列に保存
  if c.t is None:
    return { case_label: None }
  return { case_label: load(cx, ptr, c.t) }

load_flags

load_int で整数値を読み取り、それを unpack_flgs_from_int で bitvector を { flag名: boolean } な辞書配列に読み出し

def load_flags(cx, ptr, labels):
  i = load_int(cx, ptr, elem_size_flags(labels))
  return unpack_flags_from_int(i, labels)

def unpack_flags_from_int(i, labels):
  record = {}
  for l in labels:
    record[l] = bool(i & 1)
    i >>= 1
  return record

ownborrow は resource hanle index を load_int で読み取り、lift_ownlift_borrow で resource に lift する。

tanishikingtanishiking

これで一応 async を無視した Canonical ABI を一通り読むことができた。正直 TaskSubtaskResource の扱いがいまいちピンと来ていないが、また必要になったタイミングで深堀りしていこう。少なくともコンパイラ開発側で必要なことはだいたい分かった気がする

  • realloc が何をするのか(結局 Canonical ABI から雰囲気でどういうことをしないといけないのか読み取ったけど、ちゃんと定義はされていないな? component が export していればいいのか?)
  • canon lift/lower が component level の値 と core wasm value をどう変換するのか
  • Resource って具体的には何?deconstructor で drop されたときの挙動を追加できる i32 の値

ところで最近 stream 型や future 型が追加されたりしているし https://github.com/WebAssembly/component-model/commit/f19cb90538ab6cb3c765e26389583c4b8be09080 なんかいろんな変更が入ってきている...


Canonical ABI までちゃんと読んだ感想としては、やっぱり Wasm Component Model、複数のやりたいことが合体して設計されているように思える

  • 高級な型を使いたい・その bindgen を... (WIT)
  • 高級な型作るなら ABI というか変換ルールほしいよね (Canonical ABI のcanon lower/lift)
  • ところで module の合成したいよね (component)
  • 将来的に Async とかやりたいから CanonicalABI もそれに合った設計にしたいよね

みたいな感じ?でその結果、めちゃくちゃ大きな変更になってしまっている。

まあなんか、そういういろんなやりたいことを実現するための方法を全部ちゃんと考えたうえで設計しているので偉いとも言えるし、その結果めちゃくちゃ大きなproposalになっていてちゃんと安定するにはめちゃくちゃ時間かかりそうだなみたいな話もある。

高級な型を作ってやりとりしたいとか、module の合成をしたいとか、async な挙動を実現したいとかを別々に解決していこうと思うと、また違ったアプローチができる気がする? (Extism/xtpや、multiple memory は static linking に役に立ちそう https://github.com/WebAssembly/multi-memory/blob/main/proposals/multi-memory/Overview.md だし、async みたいなのは stack switching とか threads とどう解決する問題が違うのかもよく分かってない)。

そういう考えもあって、Component Model は野心的で面白いねーと思う一方で、これをベースに WASI preview 2 を作って話が進んでいるのはなんかおかしくないか???って思う。まだ phase1 でしょこの proposal

感想でした。

このスクラップは7日前にクローズされました