wasm_core (wasm v0.1.0)

View Source

Names and bounds for generated code.

You get here if you turn a WebAssembly function into Core Erlang. Read this before you generate a name, because a Core function identifier has to be an atom and the atom table is node-wide and never reclaimed: a name derived from a module's own bytes is a permanent leak with a remote attacker holding the tap.

wasm_code_slots solves that for module names by writing sixteen of them out longhand. Function and frame names cannot be written out longhand, because QuickJS has 1666 compilable functions, so they are computed instead -- once, at first use, from a bound that is a literal in this file. The property is the same one and is what matters: the set of atoms this module can ever create is decidable by reading it, and nothing user-controlled reaches a name.

The bounds, and where they came from

bench/paths/subset.erl reports the shapes a generator is bounded by, over the Rust plugin and over QuickJS:

plugin maxqjs maxqjs p99
bound on continuation arity207137
control frames per function1181016165
control nesting1825730
compilable functions1501666

Arity is not the constraint it looks like. The worst function in QuickJS would generate a 71-argument continuation against the BEAM's limit of 255, so ?MAX_ARITY costs no coverage at all and exists to refuse the pathological rather than to shape the common case. The decoder admits a million locals (\wasm_decode:expand_locals/1) and that is what a bound has to survive, not what it has to accommodate.

Frame names are bounded by nesting, not by count. A function with 1016 control frames needs far fewer than 1016 names, because Core letrec scoping puts a sibling frame's name out of scope: names have to be unique only along a nesting path, which is 257 deep at worst.

A module or function past a bound is not compiled. That is the same answer as every other refusal here: interpret it.

Summary

Functions

Every atom this module can create. The whole set, for tests to assert on.

Whether a function can be compiled, and what it would cost if so.

The Core Erlang this unit lowers to, before the OTP compiler sees it.

The name of the Nth control frame along one nesting path within a function.

The name of the Nth function of a compiled unit, counting from zero.

Every bound, so a caller can refuse before it starts rather than part way.

Generate and compile one BEAM module for a set of WebAssembly functions.

As module/4, choosing how hard the OTP compiler works.

Every operation wasm_exec:op1/2 and op2/3 are expected to implement, with the number of operands it takes.

Whether the generator can emit code for one lowered instruction.

Functions

atoms()

-spec atoms() -> [atom()].

Every atom this module can create. The whole set, for tests to assert on.

can_compile/2

-spec can_compile(#fn{nparams :: non_neg_integer(),
                      nresults :: non_neg_integer(),
                      defaults :: [term()],
                      body :: [tuple() | atom()] | {lazy, [term()]},
                      raw :: [term()],
                      idx :: non_neg_integer(),
                      type :: term(),
                      frame :: term()},
                  [term()]) ->
                     {ok, map()} | {unsupported, term()} | {limit, atom()}.

Whether a function can be compiled, and what it would cost if so.

Answers a diagnosis rather than a boolean, because a refusal that is expected and a refusal that is a defect look identical as false, and the conformance suite's force-eligible mode has to tell them apart:

  • {ok, Metadata} — nothing known to refuse. Generation may still answer {limit, _}, because a continuation's arity is only known once the compile-time operand stack has been walked.
  • {unsupported, Instr} — outside the subset. Expected, and the common answer.
  • {limit, Reason} — inside the subset but past a bound. Expected, and rare: QuickJS's worst function nests 257 deep against a bound of 512.

forms(Name, Unit, Sigs, TSigs, Stamp)

-spec forms(module(), [term()], map(), map(), binary() | non_neg_integer()) ->
               {ok, cerl:c_module()} | {error, term()}.

The Core Erlang this unit lowers to, before the OTP compiler sees it.

Use it when you have changed a clause of the generator and want to know what it produced. module/6 calls this and then compiles the result, so what you print here is what runs, and wasm_jit:dump/1 is the way to reach it from an instance you already have:

{ok, Core} = wasm_core:forms(wasm_code_0, Unit, Sigs, TSigs, 0),
io:format("~s~n", [core_pp:format(Core)]).

This exists because the alternative is reading 1,284 lines of cerl calls and imagining the tree. A differential test tells you the generator is wrong; this tells you how.

frame_name/1

-spec frame_name(non_neg_integer()) -> atom().

The name of the Nth control frame along one nesting path within a function.

fun_name/1

-spec fun_name(non_neg_integer()) -> atom().

The name of the Nth function of a compiled unit, counting from zero.

Named by position in the unit rather than by WebAssembly index, so a module that compiles a hundred of its five thousand functions uses a hundred names.

limits()

-spec limits() -> #{atom() => pos_integer()}.

Every bound, so a caller can refuse before it starts rather than part way.

module(Name, Unit, Sigs, TSigs)

-spec module(module(),
             [{non_neg_integer(),
               non_neg_integer(),
               #fn{nparams :: non_neg_integer(),
                   nresults :: non_neg_integer(),
                   defaults :: [term()],
                   body :: [tuple() | atom()] | {lazy, [term()]},
                   raw :: [term()],
                   idx :: non_neg_integer(),
                   type :: term(),
                   frame :: term()},
               [term()]}],
             #{non_neg_integer() => {non_neg_integer(), non_neg_integer()}},
             #{non_neg_integer() => {non_neg_integer(), non_neg_integer()}}) ->
                {ok, binary()} | {error, term()}.

Generate and compile one BEAM module for a set of WebAssembly functions.

Unit is [{Pos, Idx, #fn{}, IR}], where Pos numbers the functions within this unit and Idx is the WebAssembly function index the generated invoke/5 dispatches on. Names come from the pools, by Pos, so a module compiling a hundred of five thousand functions uses a hundred names.

module(Name, Unit, Sigs, TSigs, Mode, Stamp)

-spec module(module(), [term()], map(), map(), baseline | full, binary() | non_neg_integer()) ->
                {ok, binary()} | {error, term()}.

As module/4, choosing how hard the OTP compiler works.

baseline skips the SSA optimiser. On QuickJS's 1666 functions that was 119.9 seconds down to 45.9 for 10% slower code, which looked like an easy trade and is not: the same option costs 86% on bench/cross/loop.wasm, 2.51 nanoseconds an iteration against 4.66. Ten percent was what a bytecode interpreter's dispatch loop happens to lose; tight arithmetic is what the SSA optimiser is actually for.

So full is the default. It is affordable because the two changes that came after this one made it so: compilation is off the calling process, and it is sized to the functions that ran rather than the functions that exist, so the 74 seconds is about 8 and nobody waits for it. baseline remains for a caller who would rather have the module sooner.

ops()

-spec ops() -> [{atom(), 1 | 2}].

Every operation wasm_exec:op1/2 and op2/3 are expected to implement, with the number of operands it takes.

The arity is answered here rather than guessed by the caller, because guessing it is how a unary operation gets checked as a binary one and silently passes.

supported/1

-spec supported(term()) -> boolean().

Whether the generator can emit code for one lowered instruction.

Control instructions carry a body and are answered true here; whether their contents are supported is can_compile/2's recursion, not this.