Wado

WEP: The Live ValueGraph — ValueGraph as the Pure-Value IR

This WEP redesigns the NIR optimizer for compile speed by making the ValueGraph the source of truth for pure values, instead of a side-table re-derived from the SkelTree on every pass. Pure operand positions in the SkelTree become ValueIds; the graph is built once per function, rewritten eagerly in place via e-class union, and extracted back to a skeleton form once before WIR build. The SkelTree stays the effect-and-control schedule.

This is the aegraph mid-end model (build once, eager rewrite with union-find, single extraction — Cranelift's, not equality saturation): rules apply once and destructively-by-union, never searched to a fixed point. Equality saturation stays out of scope.

Status — complete

value_of (the persisted ExprId -> ValueId side-table) is deleted — criterion 3 met. Every value the optimizer reads now comes from a promoted operand (born-as-operands) or pure re-derivation over the value pool; an unpromoted skeleton leaf resolves to None (sound: None is the finest partition, so a consumer skips the expr rather than over-merge). Build-once is the default and rebuilds = 0 (criterion 1).

Criterion 2 (optimize CPU halved) is measured and not met — and will not be pursued further (decision: 2026-06-24). package-gale optimize phase ≈ 15.7 s (dev build, rebuilds = 0), vs the ~7.5 s target. The premise was wrong: with build-once the value-graph build is now only ~6 % of the phase (was 20.76 %); the real cost is the passes themselves — peephole (≈ 4.6 s), the 5-iteration fixpoint, licm, inline — and the added born-as-operands passes (promote_fields, cond_impl_post_promote, …) roughly offset the build-once saving. A 2× win needs pass-level work (faster peephole, fewer iterations), a separate track from this value-graph re-architecture. The build-once + no-side-table invariants stand regardless (see wado-compiler/AGENTS.md): do not reintroduce rebuilds or a cache.

Validated end state: cargo test --lib 738/0; full e2e (O0+O2) 0 / 3030; mise run test / mise run test-wado green. No WADO_* promotion gate remains — the structural / born-as-operands path is the single production path.

Survives by design: nir_value_graph::builder still runs — it computes loop_entry_values (licm hoist legality) and the Builder-internal working map that build_scoped -> Engine::scoped_const_reads -> store_load_forward reads. Folding the build into lower (born-at-lower) for the criterion-2 CPU win is a separate later perf item, not a deletion prerequisite.

Retrospective (how the side-table was retired)

No single "delete the map" leap; each step was validated, full record in this branch's git history:

Dead ends (measured, reverted — do not retry as-is): promoting induction-var Local reads to source-bearing opaques traps closure_for_loop_mutation; a query-time entry-FieldAccess materialiser miscompiled ~165 fixtures (reference/aggregate fields change copy/alias semantics); keeping caller value_of across a loop-free-but-impure inline over-merges two reads of a &mut param. Throughline: a value's identity is sound only when carried by an operand the edits maintain, never re-derived from a side-table at query time.

Goal

Make the NIR optimize phase 2× faster. On the package-gale baseline (this host, dev build) the phase is ~15s; the target is ~7.5s, comfortably under 10s. Absolute time varies by host; the 2× ratio does not.

This is not a cache-tuning goal. The current optimizer already parks and carries a derived ValueGraph between passes (vg_cache / carry_vg_cache); that path is measured and capped — it shaves a few percent and leaves the graph rebuilt 2.67× per function. It is removed by this WEP, not extended.

Acceptance criteria

The work is done only when all three hold. None is satisfiable by a band-aid; each is a static or measured fact, not a "byte-identical and X% faster" proxy.

The three are mutually reinforcing: deleting the cache (3) requires removing the re-derivation it caches, which is the build-once change (1), which is what produces the speedup (2). Any one unmet means the goal is unmet.

Merge gate (overrides incidental proxies): the full suite is green — mise run test (every crate, all e2e fixtures at O0/O2) and mise run test-wado, with no pre-existing failure left standing. The operand-promotion migration is part of this: a promoted Operand::Value (e.g. a constant struct receiver of a FieldAccess) must never hit an as_expr().expect("skeleton operand") in any pass. gale_cli (kiln-generated code, which exercises promoted shapes the mainline fixtures do not) is part of the gate. Every expect("skeleton operand") site has been migrated to handle a promoted operand — none remains in the compiler source.

Context

A sampling profile (samply, 1 kHz, dev/debug wado) of wado compile -O2 package-gale/src/main.wado pins the cost. Percentages are inclusive shares of total CPU — machine-independent ratios; absolute time varies by host. optimize is 64.83% of compile CPU. Inside it the dominant cost is per-function analysis rebuilt per pass and discarded:

By pass wall-time, licm (~3.7s), cse (~2.5s), and const_fold (~0.8s) — the three value-graph passes — are together ~46% of the phase.

This cost exists for one structural reason: the ValueGraph is a derived analysis of a mutable SkelTree, and so is the engine's analysis (parent map, use index, post-order). Every SkelTree edit can stale them, so every pass that needs pure-value identity re-derives the graph, and every pass re-derives the engine. The dirty-set gate and the revision-keyed vg_cache amortise this only for functions a pass did not change; a function a pass actually rewrites pays a fresh re-derivation in the next pass that visits it. The result is 2.67 graph builds per function and a comparable engine churn — and the ~27% allocation cost that rebuilding those structures generates.

Why source-of-truth, not incremental rebuild or a richer cache

Two cheaper-looking patches were considered and rejected.

Incremental rebuild — re-derive only the changed region of the side-table — was prototyped, verified equivalent under a WADO_VERIFY_INCREMENTAL harness, and reverted: it fired on ~0.15% of builds on the large workload and 0% on the small ones, and it is the wrong shape — it makes the re-derivation of a derived side-table cheaper, when the side-table only needs re-deriving because it is derived.

A richer cache — park the graph and reuse it whenever the body is unchanged — caps low for the same reason: it still rebuilds whenever a pass changes the function, which is exactly the common case, and it cannot reach 2×. It is the current vg_cache / carry_vg_cache path, and this WEP deletes it.

So this WEP removes the reason re-derivation exists: the graph stops being a shadow of the SkelTree and becomes where pure values live. Flow is resolved into the graph once, at build, and frozen there; a rewrite is a union of two e-classes, which every user sees through find() without any re-walk. There is no derived form to bring current, and nothing to cache.

Decision

Promote the ValueGraph to the pure-value IR and drive it aegraph-style; apply the same build-once discipline to the engine's per-function analysis.

Architecture

Operand promotion

lower::translate emits a ValueId for every pure operand position instead of a pure ExprKind. The pure literal / Binary / Unary / Cast variants leave the SkelTree; the slots that referenced them hold ValueIds. Effectful and control-flow ExprKinds keep their skeleton form, with their pure operands promoted. WIR build no longer matches the pure ExprKind arms — it consumes the extractor's output. This is a wide but mechanical change across the arena, lowering, WIR build, the unparser, and the passes that match pure ExprKinds; it is load-bearing, so its breadth is accepted rather than worked around.

The graph as IR

The existing builder already resolves a function into the frozen-flow form this needs: it threads current_value, the heap versions, and reference targets in one linear walk, and constructs Select at merges and LoopPhi at loops. The redesign runs that build once per function — at lower — and then maintains it. Two additions make it an IR rather than a side-table:

Optimizations that become graph properties

Once pure values live in the graph, a cluster of today's passes is subsumed and their per-pass walks disappear:

What stays a skeleton rule

Structural and effectful rewrites do not reduce to a graph union and stay rules on the worklist engine: block flattening / dead-statement pruning, match_to_switch, labeled_block_fusion, ref_elim, elide_box_local, value_copy_*, sroa, container_sroa, field_scalarize, inline, dae, drve, const_object_globalization, dce. A skeleton rewrite that changes control flow keeps the graph coherent pointwise: pruning a branch unions the dead Select arm's class into the surviving arm; splicing an inlined body or an SROA split builds value nodes for the new skeleton subtree (monotone graph growth at the splice point, not a re-derivation of existing flow).

This pointwise maintenance through structural edits is the load-bearing claim of the whole design and its chief risk: inline and sroa must grow the graph at the splice/decomposition point without re-walking the untouched remainder. The roadmap measures this before the wide operand-promotion change is committed (see Roadmap, de-risk step).

Extraction

Before WIR build, one pass walks the SkelTree and lowers each pure ValueId operand to a concrete skeleton/WIR form, choosing per multi-use value whether to re-compute it at each use or materialise it once into a hoisted temp. This is the one genuinely new analysis and the main code-quality risk: the cost model must not emit worse code than today's CSE / hoisting heuristics. The migration de-risks it by reproducing the current materialisation first (extract each value at the sites and shapes the old passes produced), then improving the cost model behind benchmarks.

Soundness invariants

Consequences

Expected effect

Risks

Trade-offs accepted

Execution history

The multi-session working log — the promote-early keystone, the build-once persist milestone, the per-pass migration order, the i128 miscompile root-cause, the born-frozen constant-promotion variants, and the structural-BCE bring-up — is superseded by the achieved end state (Status / Retrospective above) and removed for concision. The full record is in this branch's git history; the durable design it converged on is captured in Architecture / Soundness invariants / Decision above.

See also