Wado

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 literal rebuilt on every call — or loop iteration — is pure waste. In particular a global ORIGIN: GPoint = GPoint { x: 0, y: 0 } was initialized at runtime inside __initialize_module even though Wasm 3.0 GC allows struct.new / array.new_fixed / array.new_default in constant init expressions.

Two problems blocked fixing this:

Decision

A constant global initializer is promoted to an eager Wasm constant by one WIR pass, gated by one predicate. The eager/lazy split is decided once, after NIR optimization has simplified the init — "lazy iff optimize could not simplify it."

One const predicate — WirInstr::is_const_expressible

A single 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 (a const init never references another global, sidestepping the core-Wasm const-expr ordering restriction) and array.new_data / array.new_elem — these read a data/elem segment at runtime and are not valid Wasm constant instructions. Codegen's push_const_instrs emits exactly this set and mirrors the predicate; a node reaching the emitter that fails the predicate is an ICE, not a silent i32.const 0.

This shapes how strings are materialized: a String's array.new_data<u8> repr is not const-expressible, so a string built that way cannot be an eager const global. translate_string_literal therefore gives a short string (≤ NirPackage::string_inline_max_bytes UTF-8 bytes) a constant array.new_fixed<u8> repr instead — one i32.const per byte — so a short constant string global does promote to eager. Longer strings keep the compact data-segment repr and stay lazy (each byte as an array.new_fixed operand would bloat code unboundedly). The threshold also skips registering a passive data segment for short strings, since the inline repr does not read one.

The threshold is opt-level-driven (optimize::string_inline_max_bytes): 4 bytes by default (including -Os, which targets size) and 8 at -O3, which trades a little code size for more eager string globals. It is measured to be roughly size-neutral either way — array.new_fixed of N bytes offsets the dropped data segment + its header — so the threshold tunes how many string globals go eager rather than overall size; the body-globalization cost below dominates size.

One classifier — wir_optimize::const_global

The eager/lazy decision lives in a single WIR pass, promote_const_global_inits, run in WIR phase 7 (before guard removal). lower/plan/globals::extract still emits each non-trivial init as an __initialize_module runtime assignment, and NIR optimization (array_literal, string_push, const_folding, …) collapses builder sequences. By WIR the assignment is GlobalSet(G, value) with value already fully lowered. The pass:

The emptied init body and __modules_initialized guard are reclaimed by init_guard / dce / cleanup in the same phase. This subsumes the scalar-only NIR const_global_promotion, which is deleted — const-ness is now decided once.

Promotion keeps lazy_init and the nullable slot as register_globals set them: a non-null const init is a valid subtype of a nullable slot, and codegen keeps narrowing reads with ref.as_non_null, correct since the eager value is non-null.

Why WIR-level, not a NIR→WIR try_const_init

An earlier design put a try_const_init classifier at the NIR→WIR boundary and relocated extract / build_initialize_modules into wir_build. Two findings redirected it to a WIR pass:

An earlier draft planned to give register_globals a path to lower an aggregate-literal initializer directly, so a body-globalized global could carry an eager aggregate init. Body globalization instead routes through the existing __initialize_module-style promotion (an inline GlobalVarSet promoted by wir_optimize::const_global), so this path is not needed and was never added — translate_global_init still handles only scalar / null inits. See "Body globalization — landed" below.

Body globalization — landed

const_object_globalization (a NIR pass, optimize/const_object_globalization.rs) hoists constant-aggregate let bindings out of function bodies into shared immutable globals. It was deferred until the original soundness blocker was removed: at -O2, arr[i] = v lowered to a builtin array_set(arr.repr, i, v), a mutation through the arr.repr projection that a "every use is a projection read" gate misread as a read. Phase 1 of the builtin::array redesign gave the builtin::array_* ops & / &mut first parameters, so the mutation is now an explicit &mut arr.repr the gate rejects.

Two independent gates:

The pass runs once after the optimizer fixpoint converges, on the stable post-inline shape. It does not re-translate the aggregate or extend register_globals: it emits a fresh Wasm-mutable / Wado-immutable global with a null placeholder init (mirroring extract) plus an inline GlobalVarSet at the original let, and rewrites the binding's reads to GlobalVarGet. The existing wir_optimize::const_global classifier then promotes the global to an eager Wasm constant and drops the assignment — reusing that machinery wholesale, so soundness rests on the read-only gate alone: if a value turns out not to be WIR-const-expressible, the global merely stays lazy-assigned each call, still correct. (This supersedes the earlier idea of a register_globals aggregate-init path, which is therefore not needed.)

Only aggregates 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 beneficiary is a constant List / Array indexed dynamically in a loop (the lookup-table case), which cannot be scalarized.

Enablers (other WEPs)

Consequences

TODO

Landed:

Deferred:

Update (2026-06-24): string/bytes literals as constant aggregates

The atomic ValueKind::String was removed. A string literal now lowers to StructLiteral String { repr: PackedArray(bytes), used: <len> } (a bytes literal to the same shape over List<u8>), where ExprKind::PackedArray is a raw constant Array<u8> — the renamed former NIR BytesLiteral, now meaning the inner array rather than a List<u8> wrapper. The short/long split (the array.new_fixed<u8> vs array.new_data<u8> choice gated on string_inline_max_bytes) moved from the old translate_string_literal onto PackedArray's WIR lowering; the String/List struct wrapping is the generic StructLiteral lowering.

Consequence: string/bytes literals are now genuine const aggregates, so this pass globalizes constant read-only string/bytes bindings with no string-specific code (is_globalizable_const accepts PackedArray as a const leaf), and "x".len() folds via project_struct_literal + ref_elim (the latter extended to substitute a &<pure inline aggregate>).