Wasm Component CanonicalABI を眺める
の続き。
自分は WasmVM 開発者ではなくて、コンパイラ開発者なのでここはなんかしらんけどVMが頑張ってくれるらしいみたいなところは適当に読み飛ばすかもしれない。
まずは canon lift
と canon 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;)))
と同義
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 closurecanon_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 lift
や canon lower
によって、component instantiate 時に生成されるclosureである canon_lift
・canon_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, Noneon_start
: a nullary function that must be called to return the caller's arguments as a list of component-level valueson_return
: a unary function that must be called after on_start, passing the list of component-level return valueson_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_lift
の caller
引数にも出てきたな?
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_lift
と canon_lower
でいくつかのメソッドが実行されているので、それを見ていく。
この後当たり前のように出てくるので先に軽く触れておくと、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 valuevi
と component level type$t
を受け取って、vi
を component level type$t
に合うように変換する -
def lower_flat(cx, v, t)
は component-level valuev
と、そのv
の型(component-level)を受け取って、core wasm value に変換する
詳しい変換(重要)はあとで詳しく見ていく
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#enter
は on_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())
どうやら enter
は lower_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)
から callee
は Task
となんか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_results
を task.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_lift
は task.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
などの謎が多少わかるはずだ
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_sync
に subtask.on_start
や subtask.on_return
が渡されている。この on_start
や on_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_start
と on_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: Task
はcanon_lower
が受け取る実行時引数なんだが、どんなもの? 呼び出し元
- この
-
canon_lift
はTask#enter
にon_start
で component-level argumets を渡し (ここから別コンポーネントの関数呼び出し) -
Task#enter
はlower_flat_values
で core wasm level arguments に変換 -
call_on_trap_and_throw
でcallee
にTask
と core wasm level arguments を渡す (☆)につながる(?) - 関数の返り値(core wasm value) は
lift_flat_values
を通してon_return
に渡され -
on_return
はlower_flat_values
でまた値を core wasm values に戻してflat_results
にセットする
なんかよくわからなくなってきた。ぐるぐる回ってる。と思ったけどこれ、task とか subtask が連鎖していく感じなのか?
なんか on_return
渡すのに一度 component-level values に変換したりしてて無駄っぽいけど、なんか理由あるのかな?
例えば WASIp2 では wasi:cli/run
の run
がエンドポイントとなるので、そこからの流れを考えていみる。
- まず VM が は component-level function である
wasi:cli/run
のrun
を呼び出す- つまり(多分)
canon_lift
の実行時引数にVMがいろんな値を渡す形になるのかな - 例えば前の例では
(func (;3;) (type 8) (canon lift (core func 8)))
が回り回ってrun
として export されている
- つまり(多分)
-
canon_lift
はwasi:cli/run run
の実行に対応するTask
を作る -
canon_lift
はtask.enter
でwasi:cli/run run
の実行を開始、lower_flat_values
でrun
の引数を 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_return
にflat_results
を書き込む
ちょっと分かってきた気がする?次はFlatteningについて調べる
次に lift_flat_values
と lower_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 listts
を受け取って、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)
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_encoding
が latin1+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
から見ていく。
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にも実装が含まれている (これかな ?)
多分 originalPtr
と originalSize
が0の場合はmalloc的なことをしてねという感じなんだろう。想定される挙動はCとかのrealloc
と同じでいいのかな。
なので、store_string_copy
の挙動は簡単で、dst_alignment
とdst_byte_length
に応じてメモリを確保し、文字列エンコーディングを変換し、確保したメモリに文字列をstore。
最終的に return (ptr, src_code_units)
なんだけど、これ src_code_units
を返すの? 異なるえんこーどしたら code units は変わるのでは? この関数は latin-1
-> utf-16
もしくは同じエンコードのときのみ呼び出されるからうまくいってるだけに見える
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)" このへんに何かあるのかもしれない?うーんよく分かりませんでした
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_length
がNone
出ない場合 - 各要素を
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だけをぎっしりstackにのせる。型情報やlabel名はload時に与えられるrecord型が知っている
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_label
が c.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
CaseType
の refines
はなんか subtyping 関連のためのフィールドっぽい???他のフィールドを指定してそのフィールドでもマッチするようにするみたいな感じなのかな?ならlabelでマッチさせるの意味わからないし1つのフィールドしか指定できないのもよくわからない。...
このcommit で突然消えて binary レベルでは refines
は 0x00
が求められるようになった。どういう意思決定があったのか commit message に書こうよ...
消えた部分のコメントには...
The [subtyping] between all these types is described in a separate
subtyping explainer. Of note here, though: the optional
refines
field in thecase
s ofvariant
s is exclusively concerned with
subtyping. In particular, avariant
subtype can contain acase
not present
in the supertype if the subtype'scase
refines
(directly or transitively)
somecase
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
vsf32
ならi32
を返す- reinterpret で 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 が分かった...
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
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 じゃなくていいのかな) -
$f
はi32
(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 -> []
らしい
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))))
))
)
lower_own / lower_borrow
Resource型がどういうものかなんとなく分かってきたので、lower_own/borrow
を見ていく。
lower_borrow
と lower_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_borrow
もResourceHandle
オブジェクトを作成するのだが、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実装しないとイマイチぴんとこないなぁ
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_flat
はreturn_
と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 との関係性は???
ところで、この vi
は CoreValueIterator
という、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_flat
は lower_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はさっき見たね
(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))
alignement
と elem_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_type
はU8Type
を返し、その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 を見るぞ
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_range
は lower_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
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
own
と borrow
は resource hanle index を load_int
で読み取り、lift_own
や lift_borrow
で resource に lift する。
これで一応 async
を無視した Canonical ABI を一通り読むことができた。正直 Task
や Subtask
、Resource
の扱いがいまいちピンと来ていないが、また必要になったタイミングで深堀りしていこう。少なくともコンパイラ開発側で必要なことはだいたい分かった気がする
-
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
感想でした。