Wado

WEP: Worklist-Driven NIR Rewrite Engine

This WEP sets the terminal architecture for the NIR optimizer: a two-tier IR (structured effect skeleton + hash-consed pure-value graph) driven by a single worklist engine, with optimizations expressed as declarative rewrite rules under equality-saturation semantics, and a call-graph-driven interprocedural driver replacing the global fixed-point loop.

The engine substrate, the arena NIR, the routing of every intra-procedural pass through the engine edit API, and the ValueGraph foundation (kinds, hash-cons pool, per-function builder) have all landed, along with the Stage 3 – 6 rule migrations onto the ValueGraph — culminating in the retirement of niri's per-local field_env (the ValueGraph now owns all field reaching-def; one int128_cast reinterpret assert is a +36-byte code-size residual deferred to mod_ref.rs). The interprocedural worklist (Stage 9) has landed: the interprocedural passes now pull their candidate set from the gate's dirty set rather than scanning every function. The required path is complete. Full Layer-2 promotion (Skel pure-ExprKind retirement and saturation-driven engine) stays in this WEP as the terminal ideal but is gated behind measurement — see "Why Layer 2 promotion is deferred" and the "Optional acceleration" entries in the migration plan.

Context

The optimizer was historically ~31 independent passes, each a full mutating walk over every function, run inside a global fixed-point loop. The engine substrate (nir_engine.rs) added a parent map, a local use index, a mutating edit API, a Rule trait, and a per-function dirty-set gate (optimize/gate.rs). Every intra-procedural pass has been migrated to run as a Rule (or per-function standalone session of one) on it, and every mutation goes through Engine::* so the parent map and use index stay coherent.

What that gave us is the arena NIR, the parent map + use index, the pooled EngineBuffers, and every intra-procedural rule running through the engine edit API (see the "Completed substrate" checklist below for the exact pass list).

What it has not yet given us is the architectural win the redesign exists for:

A native profile of wado compile on package-gale (34.5k lines) pins the surviving cost to per-pass analysis reconstruction: optimize is ~52 % of the whole compile, with the loop sweeping the program ~85 times at -O2, each sweep rebuilding its own per-function dataflow.

Decision

The terminal optimizer architecture is a two-tier NIR + equality-saturation engine.

Two-tier IR

ValueGraph kinds

ValueKind:
  Int / Float (bit pattern) / Bool / Char / String / Null / Unit   (literals)
  Opaque(OpaqueId)                                                 (params, unknown values)
  Binary(NirBinaryOp, ValueId, ValueId)
  Unary(NirUnaryOp, ValueId)
  Cast(ValueId, TypeId)
  Select(cond: ValueId, then: ValueId, else: ValueId)              (structural merge)
  LoopPhi(entry: ValueId, body_iter: ValueId)                      (loop recurrence; tagged opaque in MVP)
  FieldAccess(receiver: ValueId, field, heap_ver: HeapVersion)

Notably absent: no Local kind. Locals are resolved at ValueGraph build time via a current_value: HashMap<local_idx, ValueId> the builder threads through its walk. At Let / Assign the entry is updated; at structural merges (If / Match / Switch endpoints) a fresh Select value is constructed; at Loop entry the local becomes a LoopPhi (tagged opaque in MVP, with simple-induction recognition added at the flow-sensitive migration stage). Function parameters seed current_value with Opaque values.

StructLiteral / TupleLiteral / ArrayLiteral stay Skel-side for the MVP. They are pure but allocation-bearing; const_object_globalization already covers the constant-sharing case, and Value-graph promotion needs an extraction policy that decides when two identical literals may share an allocation. Deferred until measurement justifies it.

Heap modeling

FieldAccess carries a heap version the builder bumps when a Skel node may write the heap (Assign-to-FieldAccess; non-pure Call / MethodCall / IndirectCall; LoopPhi body-iter when the body may write). MVP uses per-field granularity (a write to obj.f bumps the f slot only); a later stage promotes to per-(receiver-root, field) using mod_ref.rs. A global per-function heap counter is too coarse to make FieldAccess CSE useful in practice and is skipped.

Optimization as rewrite rules

trait Rule {
    fn apply_value(&self, e: &mut Engine, v: ValueId) -> bool { false }
    fn apply_skel(&self, e: &mut Engine, n: NodeRef) -> bool { false }
}

apply_value rules pattern-match on a ValueKind and either add a new ValueId (an "equivalent representation" in e-graph terms) or replace the kind. apply_skel rules reshape the skeleton (block flattening, statement fusion, control-flow lowering).

CSE / GVN / copy propagation / constant folding / store-load forwarding all reduce to one principle: same ValueId means same value. Two expressions sharing a ValueId need not both be computed; an operand reading the same ValueId as a known literal is itself that literal.

Saturation and extraction (optional acceleration)

The terminal architecture runs all rules together until either no new ValueIds are produced and no Skel rewrites fire, or a budget hits (node count budget, per-ValueId rewrite count, outer iteration limit — all three). After saturation, an extraction pass walks the SkelTree picking a cost-minimal Skel form for each Operand::Value(...). A multi-use ValueId is materialised once with a hoisted let __t = ... only when the cost model favours sharing over duplication — that is the "real CSE" decision.

Equality saturation is optional acceleration: the required path runs rules destructively as today, with the ValueGraph supplying hash-cons equality and reaching-def-by-construction. Saturation (Stage 8) adds phase-ordering dissolution and algebraic exploration on top, and is deferred until measurement justifies it.

Why two-tier (not classical SSA)

Wado emits structured Wasm, so SSA + relooper buys nothing the backend doesn't already need. Two-tier keeps SkelTree as the effect-ordering substrate; the ValueGraph is a side representation accessed via Operand::Value, with local versioning expressed by structural Select nodes at merges rather than explicit phis. Full SSA / sea-of-nodes stays rejected, as does dropping the SkelTree (Wasm codegen needs the effect ordering it carries).

Why Layer 2 promotion (Stages 7 – 8) is deferred

The ValueGraph as an engine side-table (Stages 1 – 6) delivers hash-cons equality, reaching-def-by-construction, and analysis sharing. Promoting it into the SkelTree (Stage 7: Operand::Value replaces pure ExprKind variants) and switching the engine from destructive rewrites to equality saturation (Stage 8) unlocks only algebraic exploration on top of the side-table baseline: re-association, distributive law, strength-reduction-per-use, cost-based share-vs-duplicate. The required optimisations Wado actually carries (allocation elimination, structural cleanup, dense-Match lowering, bounds-check elimination, inlining) are all single-direction structural rewrites — saturation buys nothing for them.

Wado's output target is Wasm, which a host runtime JITs again. Most of the algebraic improvements saturation excels at (re-association, strength reduction, De Morgan) are redone by the host JIT. Cranelift's aegraph reports 5 – 10 % improvement on native-AOT code; the corresponding number for JIT-target Wasm is much smaller because the host JIT re-does the algebraic part.

So Stages 7 – 8 stay in the WEP as the terminal ideal — they describe the architecture this design points at — but they are not part of the required path. They activate when measurement justifies their cost (e.g., a future native-AOT backend lands, or Wasm output measurably benefits from algebraic exploration the JIT cannot do).

Migration plan

The plan has two parts: the required path (Stages 1 – 6, 9) that delivers TODO② via engine-maintained side-tables and the interprocedural worklist, and an optional acceleration (Stages 7 – 8) that promotes the ValueGraph from a side-table into IR-level operands and replaces destructive rules with equality saturation.

Each migrated rule must be byte-output-identical to the pass it replaces on the full fixture + E2E suite and on package-gale, before the predecessor is deleted. If the optional acceleration ever activates, Stage 7 alters the NIR shape (pure ExprKind variants vanish) but the WIR output stays byte-identical.

Done (required path)

Open

Optional acceleration (measured-deferred)

Stages 7 – 8 promote the ValueGraph from a side-table to an IR-level substrate and replace destructive rules with equality saturation. They unlock algebraic exploration (re-association, strength-reduction-per-use, share-vs-duplicate) the required path can't — but Wasm output is re-JITted by the host, which recovers most of it, so they wait on measurement. See "Why Layer 2 promotion is deferred".

Soundness invariants

Consequences

Expected effect — required path

Measured: optimizer hot path (package-gale, dev build)

A native sampling profile (samply) of wado compile -O2 on package-gale is flat — no single function exceeds ~1.6% self-time — but the inclusive view is clear: run_gated (the gated intra passes) is ~48% of compile CPU, and inside it the dominant rebuildable cost is the per-function ValueGraph build (builder::build + Engine::value ~22%, plus flow joins join_overlay / join_heap / flow_join ~16%) and the alias-set computation (~14%) — all rebuilt from scratch per pass per function and discarded.

Two findings shaped the next steps:

So the remaining levers are (1) an incremental ValueGraph — re-walk only the mutated region of a changed function instead of rebuilding it whole (the cache only helps unchanged functions), and (2) function-level parallelism (the per-function build / walk is independent). Both are prerequisites to the ~1.5× target; the full single-worklist promotion subsumes (1).

Incremental ValueGraph (lever 1) — prototyped, measured, reverted

Lever 1's idea: instead of full-rebuilding a changed function's graph, reuse the parked graph's unchanged prefix and re-walk only the disturbed region. A working, verified prototype was built and then reverted from the tree — it is subsumed by the single-worklist promotion (Stages 7 – 8) and fires too rarely in the current multi-pass pipeline to pay for itself. The implementation lives in the branch history; retrieve it if Stage 7 – 8 work wants the verified mechanism:

Soundness held: the consumers only test value(a) == value(b) (the equivalence relation, never absolute numbering), and restoring the exact entry flow-state before the first disturbed statement reproduces the from-scratch equivalence classes. The full E2E suite passed under the verify harness and WIR was byte-identical with the rebuild on vs off.

What killed it was the fire rate, instrumented across benchmarks:

workload full builds wholesale reuse incremental
sqlite_parse (gale-generated) 16308 4856 32
fts / mandelbrot / count_prime ~560 ea ~135 ea 0

Incremental rebuild fires on ~0.15% of value-graph builds on the large workload and 0% on the small ones, so the optimise phase was within run-to-run noise of the baseline. The cause is architectural:

Raising the fire rate is not a journaling problem: inline restructures bodies wholesale every iteration and spoils any cross-iteration journal regardless. It needs the single-worklist architecture, where one pass maintains the graph across all rewrites (fire rate ~100%) — which is exactly Stages 7 – 8. So lever 1 and the single-worklist promotion are not orthogonal: Stage 8 subsumes lever 1 (the per-pass rebuild it makes incremental simply ceases to exist), and most of the prototype's code (the value_of side-table rebuild, the gate journal) would retire alongside the side-table at Stage 7. The durable takeaways — the incremental-dataflow correctness argument and the verify-harness approach — are recorded here; the rest is left in history rather than carried as a gated-off path through the optimizer's hot code.

Additional effect — if optional acceleration ever activates

Risks

Trade-offs accepted

See also