wasm_jit (wasm v0.3.0)
View SourceWhen a module gets compiled, and how a call reaches the result.
You get here from wasm, on the path an invocation takes. Everything here is a policy
decision on top of two mechanisms that already exist: wasm_core generates and
compiles, and wasm_code_slots decides which module name the result may be
loaded into and when that name may be reused.
Off by default
Compiled code is opt-in, #{compile => true} in an instance's limits, the way
fuse => false already works. Turning it on by default is a separate decision
with its own gates: differential trap and side-effect tests, an enforced
compile-time and code-size limit, and a scheduler-responsiveness measurement
while a generated loop runs.
Every failure interprets
A module the generator refuses, a slot pool that is exhausted, another process
already compiling the same module, a finite fuel budget, a compile error, a
compiler over compile_max_heap_words: all of them mean run the interpreter,
and none of them is an error. That is what
makes the tier safe to enable at all, and it is also what makes a green test
run prove nothing on its own -- see counts/0 and the compile_force option,
which exist so that a conformance run can say generated code actually ran.
Fuel
Fuel is charged at every loop back edge, and threading a budget through a compiled loop gives back what compiling it bought. So an invocation with finite fuel is interpreted, checked once here rather than in the generated code.
Three options for a conformance run, and none for production
The defaults are what an embedder wants: compile in the background, compile only what ran, and interpret anything the generator could not handle. Each of those is exactly wrong for a test that means to check generated code, because each of them lets the test pass without any generated code having run.
| Option | Default | What it changes |
|---|---|---|
compile_sync | false | Compile on the calling process, so the next call is already compiled rather than probably compiled. |
compile_whole | false | Compile every eligible function, not only the ones that have run. |
compile_force | false | Raise on a compile error instead of interpreting, so a generator bug fails rather than hides. |
compile_whole in particular is a conformance option and not a tuning knob.
Compiling every function of QuickJS is 74 seconds against about 8 for the hot
set, and it spends most of fifteen megabytes of code space on functions the
workload never calls. Specification modules are a few functions each, which is
why it is affordable there and nowhere else.
wasm_spec_SUITE's compiled phase sets all three, and then asserts counts/0
moved, because even with all three a refusal still interprets.
Summary
Functions
Called once at the end of an outermost invocation, to decide about compiling.
Wait for this instance's module to finish compiling, or give up.
The node's whole compile budget in heap words, or 0 for none.
Every bound the compiled tier applies to a request, so a caller can ask rather
than infer it from a refusal. wasm_core:limits/0 is the same idea for the
bounds a single unit has.
A compiler, waiting to be told what to compile. Started by wasm_jit_sup.
How much has been compiled, and how often generated code was entered.
Why the last compiles that did not happen did not happen.
The Core Erlang this instance's module would compile to, as text.
As dump/1, for one function index.
The entry to run this invocation through: generated code, or the one given.
The heap ceiling a compile would be given, in words, or 0 for none.
The bounded form of a compile failure's reason, as diagnostics/0 keeps it.
Record one call the interpreter made into generated code.
Give back the slot lease this instance took, if it took one.
For tests, which need to count what one run did rather than what a node did.
How many units this many functions are split into.
How many units this instance's module is actually resident in.
Functions
-spec after_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()}, map()) -> ok.
Called once at the end of an outermost invocation, to decide about compiling.
At the end, and that is the whole point. Which functions a workload runs is recorded for free by the lowering cache, and at the start of a call that record is empty: an instance asking then compiles all 1666 of QuickJS's functions because it cannot yet know that 223 of them is the answer. Asking here, when the invocation has finished and the record is full, is the difference between compiling what exists and compiling what ran.
Adopting a module somebody else compiled happens here too, so entry/3 on the
hot path is one atomics read and nothing else.
-spec await(#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()}, timeout()) -> ok | timeout.
Wait for this instance's module to finish compiling, or give up.
Compilation is asynchronous, so a caller that wants to measure compiled code, or
to warm an instance before serving with it, needs to know when it has arrived.
Answers ok as soon as the instance has adopted a slot.
-spec compile_budget_heap_words() -> non_neg_integer().
The node's whole compile budget in heap words, or 0 for none.
A heap ceiling bounds one compiler; sixteen of them under it is not a bound on the node. This is what the whole node may have in flight at once. Off by default, and like the ceiling it refuses rather than queues: a request that does not fit interprets and asks again at the next hot call.
-spec compile_limits() -> #{atom() => non_neg_integer()}.
Every bound the compiled tier applies to a request, so a caller can ask rather
than infer it from a refusal. wasm_core:limits/0 is the same idea for the
bounds a single unit has.
-spec compiler_loop() -> ok.
A compiler, waiting to be told what to compile. Started by wasm_jit_sup.
This process owns the slot reservation for as long as it holds one, which is why the work happens here rather than in a long-lived pool worker: the slot is released when its owner dies, and that is what makes a compiler that crashes or is killed cost nothing but the work it had done.
The wait has a deadline for the same reason: a child whose sender died between
start_child and the message would otherwise sit in the tree for ever.
-spec counts() -> #{atom() => non_neg_integer()}.
How much has been compiled, and how often generated code was entered.
Why the last compiles that did not happen did not happen.
counts/0 says how many were refused or failed; this says what they were.
Oldest first, a bounded number of them, and every reason normalised to a
bounded shape: a {compile, _} from the OTP compiler carries its whole
diagnostic list and an exit reason carries a stacktrace, and neither belongs in
a table that is read back for diagnosis.
crashed compiles are counted and not listed, for the same reason.
Answers [] rather than raising when the table is not there, which is what a
node that loaded these modules without restarting the application has.
-spec dump(#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()}) -> iodata() | {error, term()}.
The Core Erlang this instance's module would compile to, as text.
Read it when you have changed the generator and want to see what came out. A
differential test tells you a lowering is wrong; this tells you how. Nothing in
the tier depends on it and it compiles nothing: it builds the same unit
generate/5 builds and stops one step earlier.
{ok, I} = wasm:instantiate(M, #{}, #{}),
io:format("~s~n", [wasm_jit:dump(I)]).Answers {error, nothing_to_compile} when the generator refuses every function
of the module, which is the same condition that makes the tier decline it.
-spec dump(#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()}, all | non_neg_integer()) -> iodata() | {error, term()}.
As dump/1, for one function index.
Real modules are large and a maintainer is usually looking at one function.
wasm_core names functions by position in the unit rather than by module
index, so a whole-module dump means counting; this takes the index the module
uses.
-spec entry(#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()}, map(), fun()) -> fun().
The entry to run this invocation through: generated code, or the one given.
Called once per outermost invocation. Everything it can answer cheaply it answers first -- the tier being off, or fuel being finite -- so a workload that does not use this pays one map lookup.
-spec max_heap_words() -> non_neg_integer().
The heap ceiling a compile would be given, in words, or 0 for none.
Reads compile_max_heap_words and reports the effective value, so a caller is
told what the tier would actually apply rather than what the code defaults to.
Asking never reports: it does not log, move a counter, or clear what
wasm_code_slots is holding, because a question must not have the side effects
of a compile.
The bounded form of a compile failure's reason, as diagnostics/0 keeps it.
Bounded by structure, not by formatting. io_lib:format("~P", [R, 8]) bounds
depth: a flat million-character list at depth 8 still formats to a million
bytes, and truncating afterwards has already built the whole thing. So the two
shapes that carry unbounded data lose it here -- the OTP compiler's diagnostics
become their count, an exit reason becomes its tag -- and anything unrecognised
becomes an atom and nothing else.
Exported because it is the guarantee diagnostics/0 rests on, and a guarantee
that cannot be checked directly is not one.
-spec reentered() -> ok.
Record one call the interpreter made into generated code.
A diagnostic on a hot path, and it is here rather than inlined so that its cost is one place to look when it is time to decide whether to keep it. Without it a green run says nothing: re-entry that silently never happens looks exactly like re-entry that happens and does not pay.
-spec release(#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()}) -> ok.
Give back the slot lease this instance took, if it took one.
Called from wasm:destroy/1 beside the memory, table and global releases, and
for the same reason: the lease names the instance, so it has to go when the
instance does. Until this existed it went only when the owning process died,
and a long-lived process serving many distinct modules pinned the slots one at
a time until the pool was gone and every later module interpreted for the life
of the node. wasm_code_slots's moduledoc describes exactly this shape as the
usage it expects and nothing called it.
Idempotent, because destroy/1 is: releasing a lease that is not there is a
no-op in wasm_code_slots, which is the same property that makes a double
release of a memory harmless.
-spec reset_counts() -> ok.
For tests, which need to count what one run did rather than what a node did.
-spec shard_count(non_neg_integer(), map()) -> pos_integer().
How many units this many functions are split into.
Pure, and exported so the policy can be asserted without running a compile.
wasm_core draws every name a unit can use from a pre-generated pool, because
nothing a guest supplies may become an atom, and that pool is max_funs deep.
A unit past it is refused, so the split exists to keep each unit under it and
for no other reason: a guest that fits in one unit stays in one unit and its
generated code is unchanged.
The answer is the number of bins requested. bins/3 drops empty ones, so
asking for four on a one-function unit gives one actual part; shards/1 is
what reports the actual count.
-spec shards(#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().
How many units this instance's module is actually resident in.
Zero when nothing is compiled, and otherwise the length of the contiguous chain
starting at shard one. shard_count/2 says how many units were asked for;
this says how many there are, which is what an acceptance run has to assert
rather than infer from a wall time.