wasm_exec (wasm v0.1.0)
View SourceThe interpreter.
Nothing here is called directly; you arrive through wasm:call/3. Read it when
you want to know why execution behaves as it does on the BEAM, or before you
change the dispatch loop.
Continuation lists, not a program counter. WebAssembly control flow is
structured: br N can only exit N enclosing blocks or jump back to a loop
header. Nothing can jump to an arbitrary offset. So the instruction stream
never needs to be flattened and there is no program counter at all. A block's
body is a nested cons list, and entering one pushes a control frame holding
the list to resume on exit.
This is the opposite of what a C runtime does, and the benchmarks are why.
Walking a cons list measured 3.7 ns per instruction; the flattened
bytecode-plus-index shape that every C interpreter uses measured 5.6 ns,
because element/2 with a runtime index is a bounds-checked operation while
matching a list head is a dereference the compiler turns into a jump table.
Porting Wasmtime's IR shape would have cost about 35% for nothing.
Saved stack tails instead of heights. A control frame stores the
operand stack list as it was on entry, not its depth. Leaving a block
normally then costs nothing at all (validation guarantees the stack is
already correct), and branching out is take(Arity) ++ SavedTail. Tracking
an integer height would mean maintaining a counter on every push and pop.
Explicit call frames. Calls push onto frames rather than recursing
through Erlang. Erlang has no first-class continuations, so recursion would
make suspension impossible; explicit frames keep the whole execution state a
plain term that can be captured, inspected from another process, and bounded.
Fuel. Charged at back-edges and calls only. Every unbounded execution has to pass through one or the other, so bounding those bounds everything while straight-line code pays nothing. Fuel is there for resource limits and metering, not for scheduler safety: every dispatch step here is an Erlang function call and therefore consumes a reduction, so the BEAM preempts this loop whether or not you enabled fuel. A pure-Erlang interpreter cannot monopolise a scheduler, which is precisely what a NIF-based one can.
Summary
Functions
Invoke function FuncIdx with Args.
A call out of compiled code into an interpreted function.
Invoke, converting an escaping exception into an error.
Charge one level of call depth, or raise what enter/5 raises.
Take away a budget, restoring whatever an enclosing invocation had.
Drop a passive data segment, answering the new state.
Read a global that is an inline value rather than a shared cell.
An indirect call out of compiled code.
A memory load, for generated code. Bounds-checks and traps as the interpreter does.
Copy a range between two memories, or within one.
Fill a range of a memory with one byte.
Grow a memory, answering the old size and the new state.
Copy from a passive data segment into a memory.
The size of a memory in pages.
A unary operation on one operand value.
A binary operation on two operand values. Traps exactly as the interpreter does.
Install a budget for an outermost invocation, answering what to restore.
Write a global, and record the mutation so a trap does not lose it.
A vector load, for generated code.
A vector load into one lane of an existing vector.
A vector store. Writes bytes in place, so #mut{} does not change.
A store of one lane of a vector.
A memory store. Writes into atomics in place, so #mut{} does not change.
Functions
-spec call(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), [term()], map()) -> {ok, [term()], #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}}.
Invoke function FuncIdx with Args.
-spec call_out(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), [term()], non_neg_integer(), module(), non_neg_integer()) -> {[term()], #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}}.
A call out of compiled code into an interpreted function.
call/5 rebuilds fuel and depth from the ?BUDGET process dictionary
(init_state/4) and so does not inherit a compiled caller's depth. This takes
it explicitly, which is what keeps max_depth meaning the same thing on both
sides of the crossing.
Priced before it was written, in bench/paths/callcost.erl: 37.4 to 43.4 ns
against the interpreter's own 43.5 to 44.7 for the same call.
-spec call_toplevel(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), [term()], map()) -> {ok, [term()], #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}}.
Invoke, converting an escaping exception into an error.
Only your own call is the outermost frame; a nested invocation lets the
exception keep unwinding, which is what foreign_call/5 relies on.
-spec check_depth(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, non_neg_integer()) -> ok.
Charge one level of call depth, or raise what enter/5 raises.
Compiled recursion runs on the Erlang stack, which grows on the process heap rather than overflowing, so without this a runaway one exhausts memory instead of trapping.
-spec close_budget(term()) -> ok.
Take away a budget, restoring whatever an enclosing invocation had.
-spec data_drop_at(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer()) -> #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}.
Drop a passive data segment, answering the new state.
-spec global_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer()) -> term().
Read a global that is an inline value rather than a shared cell.
-spec indirect_out(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), non_neg_integer(), term(), [term()], non_neg_integer(), module(), non_neg_integer()) -> {[term()], #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}}.
An indirect call out of compiled code.
indirect_target/4 already does the whole resolution -- bounds, null, and the
type check against the canonical type -- and already tells a target in this
instance from one in another, so it is reused rather than restated and the three
traps it raises stay identical. It wants an #st{}, and the two fields it reads
are the two given here.
Every target crosses back into the interpreter, including one that was compiled alongside the caller. Turning that into a local call needs a per-module dispatcher and is worth its own measurement.
-spec init_state(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, [term()], map()) -> #st{stack :: [term()], locals :: tuple(), frames :: [tuple() | toplevel], inst :: #inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, mut :: #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, fuel :: non_neg_integer() | infinity, depth :: non_neg_integer(), max_depth :: pos_integer(), code :: undefined | {module(), non_neg_integer()}}.
-spec load_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), pos_integer(), atom(), non_neg_integer()) -> term().
A memory load, for generated code. Bounds-checks and traps as the interpreter does.
-spec memory_copy_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), non_neg_integer(), term(), term(), term(), 32 | 64, 32 | 64) -> ok.
Copy a range between two memories, or within one.
-spec memory_fill_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), term(), term(), term(), 32 | 64) -> ok.
Fill a range of a memory with one byte.
-spec memory_grow_at(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), term(), 32 | 64) -> {integer(), #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}}.
Grow a memory, answering the old size and the new state.
-spec memory_init_at(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), non_neg_integer(), term(), term(), term(), 32 | 64) -> ok.
Copy from a passive data segment into a memory.
-spec memory_size_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer()) -> non_neg_integer().
The size of a memory in pages.
A unary operation on one operand value.
A binary operation on two operand values. Traps exactly as the interpreter does.
Install a budget for an outermost invocation, answering what to restore.
wasm:call/4 is the only caller. A nested one inherits what is already there.
-spec set_global_at(#inst{id :: reference(), ckpt :: reference(), entry_key :: undefined | reference(), types :: tuple(), funcs :: tuple(), exports :: #{binary() => {func | table | mem | global, non_neg_integer()}}, elems :: tuple(), datas :: tuple(), globaltypes :: tuple(), tags :: tuple(), canon :: tuple(), fields :: tuple(), kinds :: tuple(), supers :: tuple(), heap :: undefined | wasm_heap:heap(), identity :: undefined | {sha256, binary()} | reference(), store :: term(), version :: term(), limits :: map(), ctx :: term()}, #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), term()) -> #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}.
Write a global, and record the mutation so a trap does not lose it.
The checkpoint is the reason this is a named helper rather than a setelement/3
open-coded in Core: every store mutation has to be visible to the invocation's
catch, or what a trapping call wrote is silently rolled back in compiled code
and kept in interpreted code.
-spec simd_load_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), atom(), non_neg_integer(), 32 | 64, pos_integer(), term()) -> term().
A vector load, for generated code.
-spec simd_load_lane_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), atom(), non_neg_integer(), 32 | 64, pos_integer(), non_neg_integer(), term(), term()) -> term().
A vector load into one lane of an existing vector.
-spec simd_store_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), non_neg_integer(), 32 | 64, term(), term()) -> ok.
A vector store. Writes bytes in place, so #mut{} does not change.
-spec simd_store_lane_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), atom(), non_neg_integer(), 32 | 64, non_neg_integer(), term(), term()) -> ok.
A store of one lane of a vector.
-spec store_at(#mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}, non_neg_integer(), pos_integer(), atom(), non_neg_integer(), term()) -> ok.
A memory store. Writes into atomics in place, so #mut{} does not change.