WEP: Constant Object Globalization
Context
Wado has value semantics: a struct / array / tuple literal builds a fresh heap
object every time it is evaluated. A constant-shaped, read-only value rebuilt on
every call — or every loop iteration — is pure waste, and Wasm 3.0 GC allows
struct.new / array.new_fixed / array.new_default in constant initializer
expressions, so such a value can be built once at instantiation instead.
Decision
Const-ness is decided once, by one predicate, in one WIR pass that runs after NIR optimization has simplified initializers — "lazy iff optimize could not simplify it". A NIR pass feeds that machinery by hoisting qualifying values out of function bodies into globals.
The const predicate — WirInstr::is_const_expressible
One recursive predicate (wir.rs) is the authority on const-ness for global
initializers. It accepts scalar consts, ref.null / ref.i31 / ref.func,
struct.new / array.new_fixed / array.new_default with const children, and
a transparent ref.as_non_null wrapper (aggregate constructors wrap non-null
ref fields in it but already yield a non-null ref, so it is dropped in const
context).
It excludes global.get, keeping a const init clear of the core-Wasm
const-expr ordering restriction, and array.new_data / array.new_elem, which
read a segment at runtime and are not valid Wasm constant instructions.
Codegen's push_const_instrs emits exactly this set. A node that reaches the
emitter and fails the predicate is an ICE, never a silent i32.const 0.
String representation
A string literal lowers to StructLiteral String { repr: PackedArray(bytes), used: <len> }, a bytes literal to the same shape over List<u8>;
ExprKind::PackedArray is a raw constant Array<u8>. Strings and bytes are
therefore ordinary const aggregates, with no string-specific code in the passes
below.
PackedArray's WIR lowering picks the repr by size. A string of at most
NirPackage::string_inline_max_bytes UTF-8 bytes gets a constant
array.new_fixed<u8> repr — one i32.const per byte — and registers no data
segment, so it can promote to an eager const global. A longer string keeps the
compact array.new_data repr and stays lazy, since spelling every byte as an
operand would bloat code unboundedly.
The threshold is opt-level-driven (optimize::string_inline_max_bytes): 4 bytes
by default, including -Os, and 8 at -O3. It is measured to be roughly
size-neutral — array.new_fixed of N bytes offsets the dropped data segment and
its header — so it tunes how many string globals go eager rather than overall
size.
The classifier — wir_optimize::const_global
promote_const_global_inits runs in WIR phase 7, before guard removal.
lower/plan/globals::extract emits every non-trivial initializer as an
__initialize_module runtime assignment, NIR optimization collapses builder
sequences, and by WIR the assignment is a GlobalSet(G, value) with value
fully lowered. The pass:
- Considers user-immutable globals (
g.mutable && !g.wado_mutable), which are Wasm-mutable only because their init was extracted.global mutis excluded. - Resolves each assignment through
is_const_expressible, seeing through the builder-tempSeq(__b = struct.new …; __b) an array literal leaves. When every assignment to a global is constant, it moves the value into the global's eagerinit, marks it immutable, and drops theGlobalSets. - Recurses into nested instructions: an inlined
__initialize_moduleputs itsGlobalSetinside an__inline___initialize_modulesguard block, duplicated per entry export, which a top-level-only scan would leave lazy.
dce / cleanup reclaim the emptied init body and the
__modules_initialized guard in the same phase. Promotion leaves lazy_init
and the nullable slot as register_globals set them — a non-null const init is
a valid subtype of a nullable slot.
The classifier sits at WIR because the value is already correctly lowered there
— variant representation, non-null field wrapping and builder collapse all baked
in — so it reuses the real translator's output instead of re-translating a NIR
aggregate. Keeping extract in place also keeps lazy initializers flowing
through the TIR lower/plan boxing / closure / value-copy passes they depend
on; a const init needs none of those.
Body globalization — const_object_globalization
This NIR pass (optimize/const_object_globalization.rs) hoists a qualifying
value out of a function body into a shared immutable global. It runs once after
the optimizer fixpoint converges, on the stable post-inline shape.
It emits a Wasm-mutable / Wado-immutable global with a null placeholder init,
mirroring extract, plus an inline GlobalVarSet where the value was built,
and rewrites the binding's reads to GlobalVarGet. The classifier above then
promotes the global and drops the assignment. Soundness therefore rests on the
gates alone: a value that turns out not to be const-expressible merely leaves
the global assigned at runtime, still correct.
Three shapes are matched, collected in a single exhaustive walk:
- A
letbinding of a qualifying value. - A qualifying value referenced via
&directly at an expression position with no enclosinglet— the shape a synthesizedserdefield key takes (st.field(&"id_str", …)). It is rewritten in place: theUnary::Ref's inner expression becomes{ GlobalVarSet(G, …); GlobalVarGet(G) }. - A qualifying value passed to a call by value — a constant header value
handed straight to
Fields::append. Rewritten in place like the&shape, wrapping the argument node itself, and gated additionally on the callee (see below).
The walk skips into a qualifying let's own value and into a hoisted argument,
because hoisting both would nest one global's GlobalVarSet inside another's
initializer — a shape the single-assignment classifier cannot see through.
Gate: closed constant expression
is_globalizable_const requires a side-effect-free constant with no free
locals: literals, nested Struct / Tuple / Array / Enum / Variant
constructors, PackedArray, and the builder-temp block an array literal leaves.
A pure call on such constants qualifies too — it is deterministic and
side-effect free, so it is a closed constant expression in the same sense.
Purity comes from optimize::mod_ref::FnEffect, a per-callee summary resolved
as a least fixpoint over the call graph, tracking globals, linear memory and
component-model I/O. It deliberately excludes the GC heap: a callee that mutates
objects it allocated itself stays deterministic to its caller, and stores is
what would let a reference escape. Without that exclusion no String-building
function would qualify.
Reads of other globals are excluded — a non-const value cannot promote.
Gate: read-only
is_readonly requires every use to be a borrowing or reading position. It is
modelled on value_copy_demote's element-immutability walk but stricter:
because the whole object is shared, even a spine mutation (push) corrupts it.
Any &mut self method, any &mut of a projection, and any assignment to the
binding or a projection disqualifies it.
A bare whole-value read in a consuming position (return, block tail, let y = xs, an aggregate element, a by-value call argument) is also rejected: the
value-copy machinery may have elided the copy treating the binding as a movable
local, which globalizing would break. By-& borrows, field / index reads and
&self methods are admitted.
Gate: callee parameter, for a by-value argument
A by-value argument is handed over uncopied when the value is fresh — a literal
always is — so the callee receives the object itself, and globalizing makes that
object shared. callee_param_readonly therefore runs the same read-only walk
over the callee's own body, anchored at the parameter's local index. A parameter
the callee writes, stores, or returns fails it, and so does a callee with no
body (an import: nothing here can prove what it does with the value) or a
& / &mut parameter.
Read-only is not sufficient on its own. A by-value parameter is the callee's own
copy, so returning a projection of it (return s.data) is legitimate — the
return-convention fixpoint even calls the result owned, which is what lets the
caller skip a defensive copy of it. Hoisting the argument invalidates that
premise: the "owned" value handed back is the shared global's storage, and the
first mutation corrupts the constant. param_storage_escapes therefore also
rejects a callee that returns, stores, or passes on the parameter's storage,
following let aliases (let r = s.data;) since they name the same storage.
The pair is what keeps this shape sound on its own terms rather than by luck: a mutating callee used to get a caller-side defensive copy that blocked the const gate first, but that copy was itself removable — nothing else stands between a shared global and a callee that writes it.
Gate: profitability
Hoisting costs a global, a guard branch, and an object that stays live for the
whole program, so it is restricted to values that own heap storage — those that
transitively own a GC array, as String and List do. A small aggregate of
scalars owns nothing: multi_value_return already lifts such a return into Wasm
multi-values and allocates nothing, so hoisting it would trade zero allocations
for a global.
Lazy-init guard
An initializer the classifier cannot promote to an eager init leaves its
inline assignment standing, where, unguarded, it would re-run on every
activation. Three shapes are non-promotable and get the guard, decided by one
predicate (needs_lazy_guard) over the hoisted value — including any sibling
lets moved into it:
- a call, never Wasm-const-expressible;
- a
PackedArraypast its eager bound (name::packed_array_is_eager, the same choicetranslate_packed_arraymakes —string_inline_max_bytesfor alet-shape global,INLINE_REF_EAGER_MAX_BYTESon top for an in-place one) whose repr isarray.new_data, not a constant instruction; - an
ArrayLiteralof scalar constants at or past thearray.new_datapromotion threshold, whichpromote_constant_arrays_to_datarewrites out of const-expressibility before the classifier runs.
The guard is if builtin::is_uninitialized(G), which reads the global's slot
at a nullable type and tests the null placeholder — the slot itself records
whether initialization has happened, so no companion flag global is needed.
The guard also pins the semantics: initialization happens at the first execution of the expression it replaced, so a callee that traps or diverges still does so, at the same point. Moving the work to module init would drag both to instantiation time.
An initializer within the eager bounds keeps the unguarded shape, since the classifier deletes its assignment outright.
Representation and scope
A global created from either in-place case — the inline & or the by-value
argument — is marked NirGlobal::prefer_fixed_string_repr, a field rather than
a name-prefix guess,
so it cannot misidentify a user-declared global sharing the pass's
__const_obj_* naming convention. WIR build gives only such a global's
GlobalVarSet value a size-bounded override
(name::INLINE_REF_EAGER_MAX_BYTES, 64 bytes) of string_inline_max_bytes, so
a realistic field name promotes eager without forcing arbitrarily large literals
eager too. wir_optimize::prune_dead_data drops any passive data segment
speculatively registered for a literal that ends up wholly array.new_fixed.
The pass is gated off any wasi:*-namespaced module: wir_build::register_globals
asserts a NirGlobal never has a WASI module_source, so a hoisted global in a
WASI-binding helper fails loudly at build time instead of dangling silently.
Only values that survive optimization are reachable targets. A const struct that
is only field-read is scalarized away by SROA before this pass runs, so the
prime beneficiaries are a constant List / Array indexed dynamically in a
loop, and a pure call building a heap value from literals.
Consequences
- Constant struct / array / tuple globals build once at instantiation; reads are
a bare
global.getwith no init flag check. - The const predicate lives in one place and codegen mirrors it.
- Short string globals are eager via a constant
array.new_fixed<u8>repr; longer ones stay lazy. - An extracted global's value is readable to
nirifrom the assignment that fills the slot, not from the placeholder in it, so a derived scalar global (global B = A + 10) and a hoisted constant aggregate alike fold at their use sites — field and element reads down to scalars, then branch pruning, then DCE of the global nobody reads. This is the cross-function constant propagation intra-function SROA cannot reach. - Cost: a marginally larger global section for constants a path may never reach, acceptable given no access-time overhead.
