wasm_jit (wasm v0.1.0)

View Source

When a module gets compiled, and how a call reaches the result.

You get here from \wasm:invoke_at/6. Everything in this module 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: 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.

OptionDefaultWhat it changes
compile_syncfalseCompile on the calling process, so the next call is already compiled rather than probably compiled.
compile_wholefalseCompile every eligible function, not only the ones that have run.
compile_forcefalseRaise 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.

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.

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.

Record one call the interpreter made into generated code.

For tests, which need to count what one run did rather than what a node did.

Functions

after_call(Inst, Limits)

-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(),
                       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.

await(Inst, Timeout)

-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(),
                  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.

compiler_loop()

-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.

counts()

-spec counts() -> #{atom() => non_neg_integer()}.

How much has been compiled, and how often generated code was entered.

dump(Inst)

-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(),
                 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.

dump(Inst, Which)

-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(),
                 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.

entry(Inst, Limits, Entry)

-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(),
                  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.

reentered()

-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.

reset_counts()

-spec reset_counts() -> ok.

For tests, which need to count what one run did rather than what a node did.