WEP: Normalized IR (NIR) Layer
Context
The compiler pipeline from lower to wir_build runs entirely on FlatPackage with TIR-shaped function bodies:
... → link → monomorphize → erase → lower → optimize → wir_build → ...
└─── FlatPackage (TIR bodies) ───┘
lower/ is a series of FlatPackage → FlatPackage transformations (wide_int, pattern, globals, boxing, closure, string, value_copy). The output is structurally the same type as the input.
This produces three problems that compound over time:
- Optimize and
wir_buildsee the union of pre-lower and post-lower TIR variants. Several passes carry match arms for variants thatlowerhas, in practice, eliminated. These arms are required for exhaustiveness but never taken at runtime. - The "lower has run" precondition is implicit. A newly written pass can rely on a pre-lower shape; the type system does not catch it.
- Variants that pre-lower passes (annotate, synthesis, monomorphize) need but post-lower passes do not, and vice versa, share one enum. Neither side can shrink without breaking the other.
A second pressure reinforces the first. Removing the AST-level desugar phase (see LSP architecture) would push additional variants into TIR. Without a downstream type boundary, every optimize pass would grow further. A boundary at lower contains the blast radius.
Decision
Introduce NIR (Normalized IR) — a separately-typed body IR. lower/'s output type changes from FlatPackage to NirPackage. optimize/ and wir_build/ consume NirPackage.
What NIR Is
NIR is structurally derived from TIR. At the moment NIR lands, its expression, statement, type, and container shapes are identical to their TIR counterparts. They are distinct Rust types — separate enums, separate visitor traits — but they represent the same data.
The content of this Decision is the type boundary, not a shape redesign:
- TIR is what flows from
annotatethroughsynthesis/effect_check/link/monomorphize/eraseintolower's input. - NIR is what flows out of
lowerthroughoptimizeintowir_build. - A function signature naming one of these never names the other.
What NIR Is Not
- Not a redesign. This WEP introduces no new node kinds, removes no variants, and changes no semantic behaviour. Wasm output is bit-identical before and after.
- Not a CFG or SSA representation. NIR keeps TIR's tree-structured shape. Wado has no borrow check, and Wasm has structured control flow; the tree shape is appropriate.
- Not the home for sugar removal. Sugar elimination is
desugar's responsibility today, on AST. Ifdesugaris later removed, sugar elimination moves tobuild_tir(AST → TIR). Neither path produces sugar that flows into NIR. - Not a replacement for FlatPackage. FlatPackage continues to be the pre-lower container; NirPackage is the post-lower container. Both exist after this WEP.
Why "Normalized"
The name signals that NIR is the canonical form of a function body after lowering has run. "Normalized" captures the subtractive direction: only variants that survive lowering are part of NIR's enum, and TIR is free to drop variants that exist solely for post-lower passes. Both enums shrink toward their minimum useful shapes, while semantic behaviour stays unchanged.
(The Mesa shader compiler also names its IR NIR. The collision is in a different domain.)
Implementation
Source files
| File | Description |
|---|---|
nir.rs |
NIR metadata: functions, globals, params, locals, and the shared leaf enums the body nodes reference. |
nir_arena.rs |
The body itself — the skeleton arena and its value operands. |
nir_visitor.rs |
The immutable visitor over arena nodes. |
nir_unparse.rs |
NIR → pseudo-Wado renderer for wado dump --nir. |
A function body is not a tree of owned nodes. It is the per-function skeleton arena, and pure operand positions carry hash-consed value ids rather than sub-expressions — see NIR Optimizer Architecture. That is a representation choice inside NIR, not a change to the boundary this WEP draws.
Data sharing with TIR
NIR reuses, without copying, everything whose identity is not expression shape:
the post-erase TypeTable (types are interned globally, not per IR), symbol and
entity ids, the name interners, and Literal. It owns its own node kinds and its
own visitor. This is the convention WirPackage already set: WirTypeId borrows
TIR-level type identity while WirInstr is a fresh enum.
Lower phase role
lower is plan + translate. plan mutates the type table, pushes additive
synthesized entities into FlatPackage, and edits function declaration shapes
(parameter shadows, lifted mut bindings); every TIR body rewrite — boxing's
&local / *ref forms, closure's Closure → StructLiteral, value-copy
insertion — runs inside the translate fold, consulting the plan structs the
analysis built. So TIR is read-only between plan and translate at the
expression-shape level.
Two body rewrites sit outside the fold.
lift_mut stays an analyze step in plan. The Let mut original = fresh_local
statements it synthesises are themselves value-copy-eligible, and the fold is
what decides their copies, so they must exist before it runs.
Pattern lowering runs at the top of translate. It rewrites expression shape, so
it cannot live in plan, and it mints the scrutinee and destructure temps whose
copies the fold decides. Those temps come after the seed. A seed reading
expressions cannot see them, and predicting them reproduces the same choice one
pass later, so the seed reads declared types instead
(Resource Ownership). Folding pattern
lowering into the translator becomes possible once match-arm bindings are
ordinary projections the fold already sees.
Inspection
wado dump --nir renders NirPackage as pseudo-Wado, paralleling --tir and
--wir.
Pattern-derived forms
Three forms coexist in NIR, each with a distinct role. This is settled design, not a migration in progress.
Match— the canonical control-flow pattern-matching form. Preserves scrutinee identity, arm order, payload bindings, or-patterns, and guards.loweremits only this;wir_build::pattern_matchcompiles it toref.test+ branch.Switch— the dense-int / dense-enumbr_tableform, materialized only by the optimizer (optimize::match_to_switch), never bylower. It mirrorsselect_lowering: a late codegen-oriented lowering, not something lowering bakes into the IR.VariantTag/VariantTest/VariantPayload— the canonical expression-value form of variant introspection: read a discriminant, test a discriminant, project a payload. Each is a scalar expression, not control flow.
The variant trio is permanent in both TIR and NIR. Rewriting it into Match was
tried and rejected: ~30 optimize passes pattern-match the trio directly, and
folding it into Match arms scatters the scalar fact across statements and
forces every pass to re-derive it — a measured regression. cm_binding's uses
(the CM discriminant byte at offset 0, and statically-known payload extractions
in lift / lower helpers) are expression-value uses of exactly this kind: correct
and final, not migration debt.
TirStmtKind::LetDestructure stays for the same reason — the elaborator emits
it and NIR preserves it for the multi-value core-builtin optimisation.
Additions
NIR's node set is not frozen. A node may be added when it makes a construct's canonical form more analyzable — normalization, not the re-introduction of a lowered concept.
The standing example is ArrayLiteral: list literals reach NIR as imperative
builder push sequences, so NIR could not see a constant array as a value at all.
optimize::array_literal materializes the node and wir_build lowers it to
array.new_fixed. See
NirExprKind::ArrayLiteral — a NIR-Materialized List Node.
Consequences
Benefits
- The "lower has run" precondition is enforced statically, by the type boundary rather than by convention.
- Risk concentrates on
lower. Optimize andwir_buildname only NIR, so the semantically interesting work stays in the smallest of the three phases. - Both enums can shrink toward their minimum useful shape independently: variants meaningful only pre-lower leave NIR, variants meaningful only post-lower leave TIR, each trim enforced by the type system.
- Removing the AST-level
desugarphase becomes safe. Moving sugar elimination intobuild_tiradds variants to TIR without propagating them into optimize.
Trade-offs
- Two parallel node-kind enums to keep in mind, which is the price of the boundary and is paid down as the two genuinely diverge.
- One more
wado dumpmode to maintain.
Out of scope
- Removing the AST-level
desugarphase. - Folding
boxing::shadow_paramsor the closure planner's$callsynthesis into the fold. Both are function-scoped preamble work and would need multi-pass or per-function buffers in the translator, which the saved walker LoC does not justify.
See also
- NIR Optimizer Architecture — the arena and value graph NIR bodies are represented in.
- Wasm IR (WIR) Layer — the precedent for a separately-typed body IR, whose data-sharing conventions NIR follows.
- LSP Architecture — the motivation for removing the AST-level
desugarphase, which the NIR boundary makes safe.
