wasm_exec (wasm v0.3.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.
Where things are
2,400 lines. Three functions carry almost all of it, and AGENTS.md singles
them out because three separate changes to them have each cost about 70% on
QuickJS while the synthetic loop measured nothing:
| function | what it is |
|---|---|
run/3 | the dispatch loop. One clause per IR instruction, in %%% dispatch |
branch/3 | control flow: taking a label, unwinding to it, in %%% branches |
do_call/4 | the call trampoline, in %%% calls |
Anything you add on those three goes through bench/paths/realbench.erl on
QuickJS, five interleaved pairs, both orderings, before you believe it.
The rest by banner: %%% call entry is where an invocation starts and where
generated code is entered; %%% bounded operand cache is the operand stack;
%%% operations, for generated code holds the helpers wasm_core emits calls
to, so the interpreted and compiled paths cannot disagree; %%% exceptions,
%%% gc, %%% simd memory and %%% bulk memory are the proposals.
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.
Call a function this generated module does not hold but a sibling does.
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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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 shard_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(), module_handle :: undefined | wasm_module_cache:handle(), leases :: undefined | atomics:atomics_ref(), 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(), module(), non_neg_integer() | binary()) -> {[term()], #mut{globals :: tuple(), tables :: tuple(), mems :: tuple(), dropped_elems :: map(), dropped_datas :: map()}}.
Call a function this generated module does not hold but a sibling does.
One wasm module may be compiled into several generated ones, and a call between them is a call, not a crossing: which unit holds which function was decided before either was generated, so the name is a literal exactly as the caller's own is. Going out through the interpreter and back in through the head of the chain instead measured 268.5 ms against 174.4 on QuickJS.
{error, _} means the sibling's slot was refilled between generation and now,
which its stamp check catches. The crossing is the right answer to that, and it
is the same one a caller with no sibling at all gets.
-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.