Wado

Builtin Storage and Side-Effect Attributes

Context

A declaration with no body, such as a core:builtin primitive, has no body to read its behavior from. Its attributes and its signature are the only sources. The optimizer asks the same few questions of every such call:

Today four attributes answer these: #[result], #[retain], #[trap] and #[linear_memory]. Each was added for one analysis and has its own vocabulary. Silence means something different in each, and some repeat. Some facts have no attribute at all. Host calls are recognized by #[canonical], and the position of cold_path has no attribute. A declaration can therefore omit a fact that a reader then guesses.

Decision

Two attributes replace the four: #[storage(...)] and #[side_effect(...)].

#[immediate(...)] says how an argument is encoded, not what the call does. It is not part of this design.

#[storage(...)]

The first argument is one of seven values:

Value Meaning Example
none The call shares no storage and keeps none i32_and, i32_load
fresh The result is new storage and holds nothing it was handed array_new
part_of_args The result is an argument's storage, or part of it array_get_ref, select, black_box
holds_args The result is new storage that holds what the arguments hold variant_case_construct, array_clone_shallow
copies_args The result is new storage that holds copies of what the args hold array_clone, array_clone_prefix
stores_args The call stores what the arguments hold into the &mut argument array_set, array_copy
opaque The optimizer assumes nothing about what the call shares or keeps

holds_args and copies_args differ in what making the result reads. A holds_args result holds the very objects it was handed, so a write through one is seen through the other. A copies_args result holds copies made as a value copy makes them, so making it reads everything the arguments reach. A reference among them still names the object it named, so the result holds what the arguments hold, as a holds_args one does.

The attribute names no parameter. The types say which ones it means:

opaque is for a call whose storage the other values cannot state, such as one that keeps an argument where neither the result nor a &mut parameter reaches. opaque in either attribute lists nothing, so it stays true whatever the call does. A call into code the compiler cannot see is opaque in #[side_effect].

len = p says the returned array holds p elements. It goes with fresh, holds_args and copies_args, the values whose result is new storage.

#[side_effect(...)]

The bare identifiers are:

Identifier Meaning
none No effect beyond what the signature states
trap The call may trap
read The call reads linear memory
write The call writes linear memory
opaque The optimizer assumes nothing the signature does not state
hint The call computes nothing, but its position is what it means
black_box The optimizer may assume nothing about the operand or the result
suspend Other tasks may run before the call returns

The signature states two facts, so no identifier lists them. A &mut parameter is written through. A declaration returning ! never returns: every call ends in a trap. That is not trap, which says a call that returns may instead trap.

A write through a &mut parameter may reach everything the referent reaches. When the call also states outside = [a] for that parameter, the write reaches only the elements of a in that range.

A hint call is never deleted, moved or merged on its own. It goes when the code that contains it goes, and it never keeps that code alive.

A fact the attribute leaves out is a fact the call does not have. A call with no trap never traps. A wrong attribute miscompiles as wrong code does, so validation checks the form of an attribute and never second-guesses its facts.

none, opaque and black_box each stand alone, except that opaque takes suspend. black_box exists for builtin::black_box, which a test or a benchmark uses to keep the work it measures from being folded away. The optimizer never deletes, moves or merges such a call, and never computes its result from the operand. Its storage is still stated, #[storage(part_of_args)], because where a value is copied depends on what the result shares, not on what the optimizer may assume about it.

Suspension

suspend is a fact of its own, beside opaque or the listing identifiers. The tasks that run while a call is suspended may read and write anything held elsewhere, so a field read through a reference is loaded again after it, and a parameter split into scalars is written back before it. A call without suspend, opaque included, reaches only what its arguments reach, linear memory and the host.

opaque alone used to imply suspension, and so cost those loads and write-backs at every call into the host, though few such calls suspend. A canonical builtin suspends only where the Canonical ABI blocks: waitable-set.wait, and the cancel builtins, which Wado lowers synchronously. The copy builtins are lowered with async, and hand BLOCKED back rather than suspend. A core Wasm asset imports nothing but its memory, so it cannot suspend. A Component Model import may, and so may a builtin that calls one, such as call_indirect_stdout_write_via_stream.

The name is the Component Model's own word for a task that stops until an event resumes it. A positive fact keeps the rule that what the attribute leaves out is a fact the call does not have.

Trap Conditions

trap alone means the call may trap at any time. Condition keys narrow it: with any of them, the conditions listed are the only ones under which the call traps.

Key Form Meaning
outside [a, …] Traps unless the range lies within array a
at [p, …] Paired with outside: the range starts at p
count p Every range in outside has p elements
unset a Traps if the element of a at its at is unset
negative p Traps if p is below zero

outside and at are arrays of the same length, and the i-th entries pair up. Without at, every range starts at 0. Without count, every range has 1 element. One key per condition and arrays for the ranges let array_copy state its two ranges without repeating a key. A single range is written as an array too, so a key has one form.

A range lies within a only when its start and its count are both non-negative and they end at or before a's length, as the Wasm instructions read them unsigned. outside = [a] also says the call does not replace a.

A call that states outside = [a], at = [i] without count and returns a value is an element accessor: it reaches the one element of a at i. It writes that element when it returns &mut, and reads it otherwise.

The attributes state what a call does when the host behaves as specified. Running out of memory is assumed never to happen, and no attribute states it. The same holds for any other failure of the host: the attributes do not list what a broken host might do.

#[storage(fresh, len = len)]
#[side_effect(trap, negative = len)]
pub fn array_new<T>(len: i32) -> Array<T>;

#[storage(part_of_args)]
#[side_effect(trap, outside = [arr], at = [idx], unset = arr)]
pub fn array_get_value<T>(arr: &Array<T>, idx: i32) -> T;

#[storage(stores_args)]
#[side_effect(trap, outside = [dst, src], at = [dst_offset, src_offset], count = len)]
pub fn array_copy<T>(dst: &mut Array<T>, dst_offset: i32, src: &Array<T>, src_offset: i32, len: i32);

#[storage(copies_args, len = len)]
#[side_effect(trap, outside = [src], count = len)]
pub fn array_clone_prefix<T>(src: &Array<T>, len: i32) -> Array<T>;

#[storage(none)]
#[side_effect(read, trap)]
pub fn i32_load(addr: i32) -> i32;

Where the Facts Live

The compiler learns a builtin's storage and side effects from its attributes and its signature. A table hardcoded inside the compiler, keyed by a builtin's name or its module, is forbidden: it holds the same facts where no reader of the declaration sees them. So a trap condition that follows from a Wasm instruction is still written in the attribute.

Besides the two facts under #[side_effect(...)], the signature states:

A pass may still name a builtin for what it computes or means: lowering it to an instruction, rewriting a call to it, or reading cold_path as a branch hint. It never names one to learn what the call shares, keeps, reads, writes or traps on.

This WEP states the vocabulary and the rules. The value each declaration takes is written on the declaration, where validation and the tests check it.

Minted Builtins

A builtin the compiler calls without a source call, such as array_clone_shallow, is declared in core:builtin like any other and carries both attributes. A call to a builtin with no declaration is a compiler bug, since no other place holds its facts.

Canonical Builtins

A #[canonical(...)] declaration in core:builtin is a body-less builtin like any other and carries both attributes. A #[canonical("wasm:<path>", …)] declaration the compiler writes for a core Wasm asset follows the next section. Its name says what it is imported as, not what the call does.

Core Wasm Imports

A function imported from a core .wasm / .wat asset is opaque: the compiler sees none of its body. The declaration the compiler writes for it carries both attributes: #[storage(none)], since it exchanges only scalars, and #[side_effect(opaque)]. realloc, an export of the "mem" core module, carries the same.

Component Model Imports

A Component Model import is declared by #[cm(...)] on an interface operation, a resource method or a world function. The adapter the compiler synthesizes for it makes a raw import call, and that raw call carries both attributes, with the same values for every import. That one rule is why the compiler needs no table to answer for them.

The facts belong to the raw call, never to the operation a program calls. An operation dispatches to a handler when one is installed, and the handler's own body states what it does.

Validation

Each of these is an error. A malformed attribute is never read as some other fact.

Roadmap

The steps land as one change. Validation comes first, so its errors list every declaration still to be written.

Known gaps