Wado Optimizer
September 19, 2026 · View on GitHub
The optimizer rewrites the Normalized IR (NIR; see WEP: NIR Layer) in place before lowering to WIR, then runs a smaller set of WIR-level passes before Wasm emission. Pass span names used by WADO_LIST_PASSES / WADO_SKIP_PASS / WADO_DUMP_PASS_* carry a nir/ or wir/ prefix.
This document is the inventory, one line per pass. The canonical pass order is the code: the run_pass sequence in src/optimize.rs and the phase sequence in src/wir_optimize.rs, each position justified in the comment beside it. Per-pass design lives in that pass's module doc.
Philosophy
When WebAssembly provides a native instruction for a feature, prefer it over a complex compiler transformation — it keeps the compiler small, leverages the runtime JIT, and produces smaller output (select for branchless conditionals, array.copy/array.fill for bulk ops, br_table for dense matches).
Optimization levels
All levels run DCE on functions, types, and globals.
| Flag | Iterations | Inline budget | Notes |
|---|---|---|---|
-O0 | 0 | N/A | DCE only + match_to_switch + backend rewrites |
-O1 | 2 | 4 | |
-O2 (default) | 15 | 16 | |
-O3 | 20 | 26 | |
-Os | 15 | 16 | strips the Wasm name section |
The inline budget counts emitted Wasm instructions on the callee's hot path, not NIR nodes — see inline for the weights. --optimize-inline-growth <pct> additionally bounds how far inlining may grow the whole unit; no level sets it by default.
The fixed-point loop exits early on convergence, so a pass must report a change only when it made one, never when it merely found work to look at. A gate_only! pass reports to the dirty-set gate alone and never extends the loop. Both macros name the pass's gate column beside it, and a drained column skips the round before the pass builds any whole-program state.
A run that reaches the cap logs it at debug level, naming the passes still reporting changes. At -O2/-Os and -O3 that is also a debug_assert: their caps are sized so the loop converges under them. -O1's smaller number of rounds and an explicit --optimize-iterations are budgets, and say nothing about convergence.
The backend-required rewrites (select_lowering, multi_value_return, freeze_pure_arith) and match_to_switch run at every level, including -O0.
Architecture
The optimizer runs on a two-tier NIR: a skeleton arena carrying effect order, control flow, and allocation, plus a hash-consed graph of pure values the skeleton reaches through promoted operands. Local rewrites are rules on a worklist engine over one function at a time, scheduled by a per-function dirty-set gate; promoted values are extracted back to concrete form once, at WIR build. Whole optimizations fall out of that structure rather than existing as passes — CSE and GVN out of hash-consing, pure copy propagation out of shared value identity.
The design, its soundness invariants, the standing "do not reintroduce" rules, and the open architectural work are WEP: NIR Optimizer Architecture. This document does not restate them.
Pipeline
optimize.rs orchestrates the NIR stages; wir_optimize.rs runs the WIR stages.
- Early DCE — remove unreachable functions/types/globals.
- Before the loop: cold-region outlining, dense
Match→Switchover global initializer bodies, then the early arithmetic promotion. - Fixed-point loop (skipped at
-O0): container SROA, peephole (pre-inline), value-copy demotion, parameter SROA, variant-return scalarization, inlining, peephole (post-inline), let-block flattening, SROA, copy propagation, dead-argument and dead-return elimination, constant folding, parameter specialization, LICM, template hoisting. - Post-loop, once: field scalarization, store-load forwarding, template-wrapper cleanup, constant-object globalization, a final folding pass, scalar-temp forwarding, and clone forwarding.
- Final DCE.
- Field promotion and the bounds-check work it unblocks (
promote_fields, then thecondition_implicationrerun andloop_version_bce); skipped at-O0. - Backend-required rewrites (all levels): select lowering, multi-value returns, and the final arithmetic freeze.
- WIR-level passes — see WIR optimizations.
NIR passes
peephole is not a pass. It is the one engine session per function that the
position-flexible rules share, each on the same worklist instead of a walk of
its own. It hosts aggregate_forward, closure_devirt, const_branch_prune,
the env-free half of const_folding, drop_value, elide_box_local, elide_local,
identity_cast, if_chain_to_match, known_case, labeled_block_fusion, match_to_bitset,
match_to_switch, ref_elim, slot_temp_sroa, string_push, and
tuple_projection. It runs twice per fixed-point iteration, before and after
inline, so each rule sees the instruction window the other exposes.
match_to_bitset, match_to_switch and if_chain_to_match run only before;
closure_devirt, ref_elim, elide_box_local, slot_temp_sroa, and
drop_value run only after.
Allocation and aggregate:
inline— replace calls to small, non-recursive functions with their body; reference parameters and receivers inline too.#[inline]raises the budget 5x,#[inline(always)]forces it,#[inline(never)]and cold call sites opt out. A callee over budget as written is re-read under the constants its callers pass. A callee whose parameters, were they constant, would fold a loop out of its body is held — nothing is spliced into it, so it stays small enough to be admitted on that folded price once the constants arrive. The hold is a bet on a later round; once the loop has converged with it still in place the bet is settled, the holds are released, and the loop runs on so the held functions receive inlining like any other.--optimize-inline-growthadditionally caps what the pass adds to the whole unit; no level sets it.cold_outline— move what acold_path()marker opens into a function of its own, soinline's cold discount describes the callee. The caller keeps the branch; the marked arm becomes a call. A region moves when control cannot leave it and every local it touches is one the call can hand over. Runs once, before the loop, so the inliner never sees the unsplit shape. It costssieve4.5% for no reason the IR shows, and a marker in the middle of a loop body is one it cannot take. A function's root block is deliberately not a region — see the pass's module doc.sroa— decompose non-escaping struct/tuple locals into scalar locals. The highest-impact WasmGC pass.container_sroa— turnList<Struct>/List<Tuple>into parallel per-field lists (array-of-structs → struct-of-arrays).sroa_param— replace a struct reference parameter with the one field the callee reads, unwrapping the box that&Tvalues allocate. A multi-field struct is scalarized on a clone, so the callers that pass a whole struct keep the original; how the callee holds the field decides whether the scalar arrives by value or by reference, and a call site that would have to read the field ahead of an effectful later argument is refused.sroa_variant_return— rewrite a variant return into a[tag, slots…]tuple, so aResult-returning call stops being one opaque boxed value to every later pass. The return-position dual ofsroa_param;multi_value_returnthen flattens the tuple to the Wasm multi-value ABI. See WEP: Variant Return Scalarization at NIR.elide_box_local— collapse a box bound once and read once into its inner value.drop_value— a value in discarded position keeps only its effects: a value-producingExprKind::LabeledBlockin statement position becomes the value-discardingStmtKind::LabeledBlock, and everybreak L: vtargeting it gives up its operand, decomposed into the statements its own operands' effects need.let _ = xs.pop()is the shape it is for.elide_localdemotes the dead binding toExpr(block)and stops there, because the element read inside theOptionmay trap and so the aggregate around it is not deletable whole. A statement counts as discarded when something follows it in its block, or when WIR will expect no value from the block's tail, whichblock_yields_valueinarena_queryanswers by walking the parent chain. Three shapes that walk has to get right. WIR sizes a value region from the owning expression's own type, so a branch of a non-unitifleaves a value even where theif's own value is dropped. A statementifat the tail of a value region yields, the one shape WIR lowers a statementifas a value. And what a branching construct tests is read whatever that construct yields, so reading a condition as a branch strips anifof the value it tests. Not extended to a discardedExpr(aggregate)statement, which does not converge againstsroa_variant_return; the pass's module doc says why.string_push— specialize a constant-ASCIIpushtopush_ascii_unchecked(skippingencode_char's UTF-8 width dispatch), and fuse a run of adjacent appends: oneinternal_reserve_uninitfor the whole run, then raw byte and string writes into the space it claimed. A run-time length is read at the start of its own group and passed to the write, since the source may be the buffer itself (buf.push_str(&buf)) — hoisting that read over an earlier write in the same run would measure a buffer the run itself grew, so a group covers only the pieces after it.value_copy_demote— demote a deep list value-copy to a shallow spine copy when its elements are provably never mutated through the binding.clone_forward— collapsearray_clone(&array_clone(&place))into a single clone, where inlining plus globalization left a read-only binding whose only reader is the outer clone.
There is no value-copy elision pass: defensive copies are chosen at the lower phase by the ownership analysis, before NIR exists, so none are reachable from here and an imprecise one is that analysis's to fix — see WEP: Ownership Analysis, which records the standing case (a by-value for binding copies each element of a List of aggregates).
Variant and reference:
labeled_block_fusion— delete the intermediate an inlined?helper leaves at its consumer, threading each producer directly to the value it yields. Recognises theOption/Resultand the[tag, slots…]sroa_variant_returnleaves in its place.slot_temp_sroa— decompose the aggregate temp an inlined helper leaves where fusion cannot relocate the consumer into the block, as in the value-producinglet x = f()?or a two-armedget_pow10. Each projected slot gets a local declared ahead of the block, so its definition dominates every read, and the exits assign it instead of building the aggregate. Takes the[tag, slots…]tuplesroa_variant_returnleaves, a struct literal whose reads cover every field, and an exit handing over the aggregate rather than its fields, which it binds and projects.closure_devirt— dispatch anIndirectCalldirectly to the functor's$call, when the callee's value traces back through bindings, borrows, blocks and struct-literal fields to oneClosureToCanonical.lower's fn-param specializer already takes the closure whose value never leaves its declaring local. Every iterator adaptor parks its closure in a struct field instead, which escapes by that rule, and the read of that field only appears onceinlinecopies the adaptor'snextinto the caller. Until thenxs.map(f).collect()pays aref.castand an indirect call through a wrapper per element. The rule binds the functor to a local of its own and has the direct call read that. The callee operand must neither write nor trap, because naming the local is what drops it, and with it whatever the trace saw through. The binding must run before the call, in a block holding both, because the devirtualized call is what makes the functor's original value dead, and a later pass may drop the region it sat in.ref_elim— drop reference bindings read only via field access, rewriting each read to the source; a shared borrow of a pure aggregate substitutes the aggregate so its projections fold.aggregate_forward— deliver a freshly built aggregate to its consumer directly, so the bindingsroasees is the literal.?leaves two hops in the way.sroa_variant_returnputs aResult-returning call into slots, so an always-succeeding inlined callee builds theOkonly for the caller to open it again and re-bind the payload. Neither hop is elidable alone:elide_localwants a local nobody reads, andcopy_propwill not propagate into one later written.known_case— a variant whose case is a compile-time fact decides its own dispatch. The first arm that case can take collapses to the payload binding it makes, so lowering emits no case test and the other arms go. AVariantTest/VariantTagover such a value folds to a constant. The shapes it takes are a value copy of a variant and anif letover a fresh construct. It declines match ergonomics, where the binding is declared at the reference type rather than the payload type the read produces, and only prunes the arms there.tuple_projection—[a, b, c].1→b. A tuple literal has no identity, so a field read of one built in place is that element; the unselected elements are dropped, so each must be deletable.identity_cast—e as TisewhenTande's type share one representation head. Newtype erasure and monomorphization both leave such casts, and the wrapper hides the operand's shape from every rule that matches on one, so it runs ahead of them.
Scalar and dataflow:
copy_prop— propagate trivial copies (let x = y) and drop the binding. A value-type copy propagates however many times each side is read when neither binding is ever written, since the sharing is then unobservable.param_spec— propagate scalar arguments agreed on by every caller without cloning; compiler items and cached clones retain their contracts for calls synthesized later. Where the callers disagree, clone the callee per binding set and substitute the reads — of a scalar argument, and of the constant fields of a struct passed by reference. A borrow and its referent are one root, so alet r = &mut cfg;that inlining leaves behind still specializes; what either name narrows away is narrowed away from both. A scalar stays where it is for a callee that writes through a reference — a compile-time frame runs such a callee for those writes, and cannot see a constant the callee no longer receives — and for one on a call cycle, whose recursion the constant decides nothing about.dae— drop parameters never read by the callee, and the pure argument at every call site. Run to its own fixed point, since dropping one parameter can leave a caller's dead; the outer loop's iteration count would otherwise track the depth of a forwarding chain.drve— make a function void-returning when every caller drops its result and its return operands are pure and nontrapping. Includes scalar results and returns inside loops.store_load_forward— forward a stored literal to a later unmodified load.elide_local— drop a binding that is never read (keeping its value if impure).let_block_flatten— hoist the leading statements out of an unbroken block-tailed binding (let x = { stmts…; tail }→stmts…; let x = tail), a reference around one (let x = &mut { … }) included, since it applies to the tail either way. Include branches and loops when the tail exposes a struct or tuple literal tosroa; preserve other control-flow regions for CTFE.scalar_forward— fold the inliner's leftover single-use pure-scalar value-parameter temps into their one use, so the backend emits the operand instead of alocal.set/local.getround-trip.const_folding— partial evaluation: constant arithmetic (anenumcase counts as one — it interns as the discriminant it lowers to), compile-time execution, immutable-global reads, constant-branch collapse, short-circuit simplification (a neutral operand keeps the other, an absorbing one becomes the result when the deleted operand can neither trap nor be observed), integer identities (x + 0,x - 0,x * 1,x / 1,x | 0,x ^ 0,x << 0,x >> 0and the commutative mirrors keep the other operand; floats are excluded, sincex + 0.0is+0.0for-0.0), and constant struct / tuple / variant values (field projection, aggregate arguments and results of a compile-time call, and struct / tuple / variant / enum patterns over a constant scrutinee, with the arm's bindings and guard — except a binding that names storage rather than a value). A constant sequence's length and elements read out of it too, whether it is a local literal or a global. An immutable global's value is read from the assignment that fills its slot as well as from its initializer, since a non-trivial initializer is extracted into module init; a global something writes through, or hands a part of to a local, is not read at all — except through a shared borrow, which is no write path, solet repr = &G.reprleaves the global readable and the local holding the bytes it names. A borrow of a literal is settled the same way, which is what lets a string view fold its bytes where the view itself is a reference field. A compile-time call runs the callee's statements —letsequences, decided branches, early returns, loops, and the expression-position blocks inlining leaves — bounded by a work budget rather than by a constant trip count, and abandons the call rather than stepping past a statement it cannot perform. It also writes: a store, an element write, an allocation and a copy all land in the value the frame itself built, and a call writing through a&mutparameter runs and writes back into the caller's place. So a container filled at compile time —pushand the growth it triggers included — is a compile-time value, and one whose elements are bytes leaves the engine as the literal a source string lowers to — as does a container literal still computing contents the engine already knows, which is what a value copy of a constant leaves behind. A closed block — one that builds its value in locals of its own, writes only to those, and yields the result — runs as a frame of its own, which is what folds a fully-constant string template to the literal it denotes. Only a frame may step past a write, since only a frame performs one; an ordinary walk keeps no value across a call that writes. A mutable local carries its scalar value between writes. What bounds that is the construct whose children run only sometimes: the locals it may write are dropped before each of its alternatives, so no arm folds against what the arm beside it assigned, and again after it, so nothing past it does either. Anifwhose condition the env decides is not such a construct — exactly one arm runs, so the walk enters it with the env intact and keeps what it writes, which is what folds a chain of decided branches each writing the next one's condition in a single walk rather than one link per iteration.const_branch_prune— simplify trivial blocks and fold a constant-conditionifto its taken arm.
Loop and field:
licm— hoist loop-invariant field-access chains and non-trapping arithmetic out of loops. A field load blocked only by an opaque&mut-call clobber of its pointee type (a may-alias, e.g.write_escaped_string(&mut buf, &s)where a caller could passbuf === s) is still hoisted, then reloaded after each clobbering statement — the clobber-free path drops the per-iteration load while an alias still sees the fresh field. An evaluation-order gate refuses when a read could observe the stale hoist.condition_implication— eliminate bounds/range checks implied false by a dominating loop guard,if, short-circuit, or early-exit; drop a constant-bounded index check; and, in a forward pass, drop a redundant re-check when an earlier access already proved the same index in bounds. A bound is a local, a field, or a constant, so a foldedarr.len()matches like any other. A two-sided check is a conjunction — written with&&, or with&as a comparison chain lowers to — and dies only when every conjunct holds: the guard answers the upper half, and the entry constant of a+ 1counting loop floors the lower one. Subsumes WIR bounds-check elimination.loop_version_bce— split a loop into a checks-deleted fast path and an unchanged slow path when a bound relation holds by per-iteration transitivity; a simple fill loop further collapses toarray.fill. Whatever the body left beside the store is replayed once at the last iteration's index, which needs those statements to write only locals and not trap. A write a branch guards is allowed only where the slow arm alone names the local and the versionifruns once. The residual also supplies the no-wrap headroom a<=guard lacks, so the fast arm can prove a conjunction's lower half. Where the entry value reads as no constant, the residual carries that lower half itself. It readsi >= FLOORat the version point, then proves the step non-negative and clear of the wrap so every later value keeps the floor. That is what versions a sieve'si = p * p; i += pmarking loop.tmpl_hoist— hoist a template string's backing buffer out of a loop and reuse it when the result does not escape the iteration. It recognises an expansion by the labelsynthesis::templatestamps on it, which is whyconst_branch_pruneleaves that block un-flattened until the fixpoint ends.field_scalarize— shadow hot GC fields in scalar locals across a loop, with dataflow-driven write-back and re-read. A nested loop is inside that scope rather than an exit from it: only the candidates a call in its body reaches are committed before it and re-read after, and an unlabeledbreakout of it joins the loop's other exits instead of committing every scalar on the spot. A bit-buffer refill loop reaches nothing and so syncs nothing, which is 6% ofcore:zlib's inflate.
Whole-program and backend:
dce— remove unreachable functions, types, string/bytes literals, and WASI imports by call-graph reachability. Repeat function/global removal until stable so empty initializers lose their once guards too.promote_fields/freeze_pure_arith(extract.rs) — freeze a pure operand position into theValueIdit denotes. Arithmetic freezes before the loop (on the clean graph, which is what makes freezing a constant leaf read sound) and again last, after every binary-walking pass; scalarFieldAccessover a stable receiver freezes between them, once SROA has settled the struct shape.match_to_bitset— lower a boolean-valuedmatchover literals and ranges, which is whatx matches { A | B | 'x'..='z' | … }desugars to, to the mask test(x - min) as u32 < range & (WORD >> (x - min)) & 1 != 0.selectpicks the word when the set spans more than one, and a contiguous set is the range compare alone. The two halves join with a bitwise&rather than&&, both being pure and trap-free, so nothing branches on the key; the cascade it replaces pays a compare per member and thebr_tablean indirect branch. Binding the scrutinee and its offset once each is what lets any scrutinee thematchtook be accepted without a read repeating work. Runs beforematch_to_switch, which takes what is left. The bounds are four members, four words, and a 32-bit scrutinee.match_to_switch— lower a dense integer/enummatchto abr_tableswitch, once it covers twelve values, which one range arm can do alone. The table replaces a cascade the predictor gets right with a single indirect branch, so it pays only once that cascade is long.if_chain_to_match— fuse a run of siblingif K == x { … }statements over one local into a singleMatch. A derivedDeserializeroutes a field through such a run, unrolled one arm per declared field and left by none of them, so a struct pays one comparison per field declared for every field on the wire. The guards are exclusive because the constants are distinct and no arm writes the local; the constant bindings between the arms (the unrolled index) move ahead of the run. No width threshold of its own — theMatchalone never tests more keys than the flat run.select_lowering— lower anifwith pure arms to a branchlessbuiltin::select.multi_value_return— emit the multi-value ABI for tuple/struct returns whose call sites destructure.const_object_globalization— hoist constant read-only aggregates, and pure calls on constants that build heap values, into shared immutable globals (see WEP). A packedArray<u8>counts as an aggregate: it is what aStringliteral leaves oncestring_push's fusion reads only itsreprand SROA takes the struct away. A field reaching the aggregate through a binding of its own still hoists — that binding is what SROA leaves of a constant it split, so its definition is substituted back in rather than wrapped in a block, a block being a runtime assignment where the point is an instantiation-time constant. A borrow a builtin receives answers the read-only question fromFunctionRef::reads_param_only, there being no body to walk. A constant a callee borrows is left alone when that callee delivers the referent back out, which would share one object across every call. Delivering it means reaching a place that outlives the borrow: handing it on as a shared-reference argument asks the same question of that callee instead, a Wasm instruction over primitives cannot keep it at all, and a local assigned from a projection is another name for the same storage rather than an escape. A hoist the later folds leave with no reader is taken back, dropping the initializer with it — unless it could trap, which is observed like any other effect.
Lowering optimizations
NIR→WIR lowering avoids a few redundant shapes, firing once during the build at all levels — for example treating the final arm of an exhaustive match as irrefutable, and lowering a primitive-element array clone to a bulk array.copy rather than an interpreted per-element loop. String and bytes literals lower to a generic aggregate, so length folding, &"…" collapse, and globalization all reuse the aggregate machinery with no string-specific paths.
WIR optimizations
wir_optimize.rs mutates the WirPackage in place after WIR build; phases run in order and may iterate.
- Type representation — nullable-ref lowering; small-variant returns to multi-value.
- Box-local elimination — substitute the field read for a
Box<T>local lowering minted, then retype the ones adjacency cannot move to the field they wrap. A by-referenceforbumps the index between a box's definition and its use, so nothing may move there. - Data flow — forward constant struct fields for constant-index bounds-check elimination.
- Library rewrites — short-string append expansion; constant-array data promotion (only where packing encodes smaller than the inline
T.constoperands, since a data segment stores each element at full width while an operand is LEB128-compressed); large-literal splitting; elision of a whole-array zero fill on a fresharray.new_default(theList::filled(n, 0)shape). - Peephole — Wasm instruction-selection rewrites with no NIR analogue.
selectreplaces a value-producingifwhose arms are both cheap, pure and trap-free; it isnir/select_lowering's dual for a shape NIR never holds, since&&/||stay one node untilemit_binary_wirlowers the short-circuit to a branch.x << n | x >>u W - nbecomes a rotate; Wasm reduces a shift count modW, so the identity holds atn == 0too. - Write-only local elimination — for locals only the WIR builder synthesises.
- Global cleanup — constant-initializer promotion, identical-global dedup, and dead-data pruning.
- Branch hints —
br_ifselection and trap-based cold/likely inference (also at-O0). - Final DCE and compaction.
A pass earns its place here only by changing the emitted Wasm. Skip-scanning
one over the benchmark, example, and fixture corpus — disabling it and diffing
the output — is what settles that; anything NIR or a sibling WIR pass already
covers leaves the bytes identical and does not belong. The exception is
split_large_array_literals, which scans as byte-neutral because no corpus
program reaches its bound: it is a JIT-pathology guard for >256-element
literals, not an optimization.
A #![wasm_module(...)] core module — the allocator — runs this same list as a package of its own, since codegen emits it verbatim. Its passes are named wir/<module>:<pass> so WADO_SKIP_PASS / WADO_DUMP_PASS_* address the two runs separately.
Branch hints are transparent annotations on if/br_if conditions: a pass looks through a hint when matching, drops it when eliminating the branch, and flips it when negating the condition. wasmtime lays the cold side out of line; -f no-branch-hinting disables the feature for benchmarking.
Shared facilities
mod_ref.rs— a conservative mod/ref summary backing the move-safety predicates (may_clobber,can_move_past).alias.rs— per-function alias analysis feeding the value-graph builder. One body walk serves both the alias analysis and the mutable-escape scan;builder_alias_setsfinishes the syntactic mutation set intomut_escaped, which is what bounds each pass's heap-write invalidation.gate.rs— the per-function dirty-set gate. Its design is the WEP's.arena_query.rs— shared arena queries (purity and trap classification, mutation and place-root checks, break-target search, the promoted-read queries). The census walk itself is onBody, memoized per session by the engine.nir_visitor.rs— the shared pre/post-order visitor traits.
Differential testing (EMI)
wado-compiler/tests/emi.rs checks the optimizer against itself: a block behind builtin::black_box(false) is unreachable at run time but visible to every NIR and WIR pass, so injecting one must leave the program's output unchanged. The barrier survives as WirInstr::BlackBox until codegen. The campaign therefore covers the WIR passes too. The design is in WEP: Compiler Fuzzing.
The material comes from three roots — the e2e fixtures, the stdlib modules carrying test blocks, and the example/ programs — which WADO_EMI_ROOTS selects among.
mise run emi-calibrate keeps the sources an empty guard leaves alone, writing the corpus to target/emi/corpus.txt and every exclusion with its reason to target/emi/calibration.txt. mise run emi-mutate then injects a payload behind the guard over that corpus, and delta-debugs a finding down to the guards that carry it under target/emi/findings/.
.github/workflows/emi.yml runs both stages nightly over WADO_EMI_SHARD=k/n shards.
Not yet implemented
Missing optimizations, one entry per pass-shaped gap. Architectural work — compile speed, graph precision, the saturation end state — is tracked in WEP: NIR Optimizer Architecture instead.
- Sparse Conditional Constant Propagation (SCCP) and interprocedural SCCP.
- Global Value Numbering across effectful nodes (pure-value hash-consing already exists in the value graph).
- Instruction combining.
const_folding's integer identities need one operand to be the constant neutral element, so what is left are the rewrites that read operand identity instead:x - x,x & x,x | x,~(~x). Hash-consing already answers that identity. - Dead store elimination.
- Strength reduction; reassociation; jump threading; SimplifyCFG.
- Cross-block copy propagation.
- Sinking pure definitions into the branch that uses them (partial DCE). The
mirror of LICM, reusing its motion-safety predicates; past effectful code
it is sound where hoisting is not.
core:log's disabled path pays two allocations for arguments its gate discards, andunwrap_or/ok_orbuild a fallback the taken path never reads. The prelude offers no lazy form on purpose (WEP: Option and Result Value Methods), so this pass is what makes the eager one free wherever the argument is pure and cannot trap. The effect system already decides that, so no language rule has to change for it. - Forwarding a local bound to a global read. The graph names no global read,
so
let s = Greaches the use only when copy propagation removes the binding — never for aString. Naming it needs a generation check at the read site, the oneFieldAccesspromotion makes by version. Until thenremarks::collect_param_gate_remarksreports the miss. - Devirtualizing effect dispatch. An operation costs a global load, an
outersave/restore, aref.castand acall_ref, none inlinable. A single non-self-delegatingimplcan lower to a direct call; typing each dispatch field precisely retires theref.caston its own. -
param_specprofitability — specialize only when the constants can decide a branch, so a chain that never folds stops duplicating code. - Valuing a writable reference field by the place it names, so the writes
made through it land there. A
&mutin a struct-literal field is a write the frame does not perform, so the region abandons at the first append — the NIR interpreter WEP's stage 3. Every interpolation reaching aFormatterstops there: the text narrows at compile time, butFormatter::padandInspect::inspectstill run, because the frame has no value for the&mut Stringthebuffield names. So a fully-constantassert x.show() == "…"keeps the whole format andInspectmachinery instead of folding to nothing, which is what thetrait_local_struct_receiver_blanketandimpl_mixed_target_method_genericgoldens carry, and`${'x'}`reaches WIR as aFormatterover a fresh buffer thatchar::fmtreads back out ofbufand appends through, where`${true}`folds to a globalized literal.WADO_TRACE=vg_fieldreports what a field read forwarded to, what a literal seeded, and which local a&mutfield borrows. - Argument promotion — pass a by-reference parameter's fields by value when
the callee only reads them, and return them by multi-value when it only
writes them. Together they retire a scratch aggregate at its allocation
site, which
sroathen finishes.sroa_parampasses one field,stored_paramsdecides the escape precondition, andmulti_value_return/sroa_variant_returnown the write-back ABI, so what is missing is passing several at once under an arity cap, and returning the written ones.param_speccovers only the constant case; a non-constant field still costs a GC load per read.core:json's number scanner is the standing case: itsScannedNumberis written by one callee and read by another, each through nothing but field access, and costs ~5% of the json-canada deserialize phase. Inlining that pair also buys caller-specific dead-field elimination, which promotion alone would not recover. - Factoring a conjunctive if-chain into a decision tree.
if_chain_to_matchfuses a run whose guards are oneK == x. A run ofK0 == x0 && K1 == x1 && …could be split on the atom that discriminates best, then nested. That reaches the hand-written dispatchers the synthesisedFieldSchema::lookuptree does not. An atom that guards another's operand range has to be tested first, or a miss becomes a trap. - Scalarizing a struct a
&mutborrows.sroatreats&mut candidateas a hard escape, exempting only a shared&argument to a non-storing callee, which is what declines the templateFormatter. Nothing else keeps that struct alive, so the escape rule is the whole obstacle, and admitting a borrow whose writes the pass can follow retires it without the frame valuing the field at all.WADO_TRACE=sroaandcopy_propreport a declined candidate. - Tail call optimization (
return_call). - Bounds-check elimination for chained sequential access (
arr[0]; arr[1]; arr[2]). - Folding a
matchwhose scrutinee is a syntactically knownVariantConstruct. The constant-scrutinee path runs throughconst_eval::Value, which is all-or-nothing constant, so "case known, payload opaque" is inexpressible there. - Folding a call whose callee is effect-free and whose arguments are all
constant, when the callee is bigger than the inliner's budget. The
standing case is a tagged template that scans its literal segments to
decide each hole. A template shape holds every segment as a constant, so
the scan reads nothing else and has one answer per hole. It folds while
the scan fits
inline_threshold(16 at -O2, 26 at -O3). Past that the call survives, and a code generator rescans a string the compiler already knows on every line it emits. Writing the per-character transition as its own function brings both halves under the budget at any state count, which is how WadoPoet'swadotag folds away entirely. So the gap costs the tag author a shape to know, not the fold.nirialready admits such a callee (is_ctfe_eligible) and--optimize-inline-threshold 200folds the undivided form; what is missing is reaching the fold without the inliner paying for the body first.tagged_template_lit_scan_fold.wadopins all three shapes. - An array literal's length as a known constant. The value graph answers
array_lenfrom what anarray_newrecorded, and a literal allocates nothing, so a length guard over one never folds. It shows on a vector literal shorter than its lane count.let v: u64x2 = [a]inlinescore:simd'sif lane < len { get } else { 0 }once per lane,sroadeclines the candidate over the read past the end, and the array survives with a live guard.[a, b]compiles to tworeplace_lanes. Recording a literal's length is a few lines. Asking for it is the missing part: the graph is built before the guard is inlined, and no later pass rebuilds it.sroacannot close the gap alone, because an out-of-range read it rewrote would have to answer with a value where the array traps.
Tried and found ineffective
- Empty-array singleton for default
Stringfields — no measurable gain; the GC allocator handles tiny zero-length arrays cheaply. array.copyforList::grow— several times slower than the element loop under current runtime JITs.