At a high level, a phase is a self-contained compiler transformation or analysis step. Phases are intended to do one thing at a time and compose in a straightforward sequence. See also the Rewriting Guide, since several phase families are built directly on top of Rewriter.
A Phase has a single entry point, run(), which wraps the actual implementation in start().
Phase is the minimal base class.
A phase provides:
A custom phase usually derives from Phase and implements start():
Run it with:
Analysis is the base class for phases that inspect the current world using the Rewriter traversal machinery. It inherits from both Phase and Rewriter, but unlike RWPhase, it rewrites into the same world. In practice, this means Rewriter is used as a structured, graph-aware traversal over ordinary MimIR Defs.
A central feature of Analysis is its lattice(), a Def2Def map that stores abstract information about old-world Defs.
This is often convenient because analysis information can itself be represented as ordinary MimIR Defs. As a result, existing IR machinery applies automatically, including:
So if your abstract domain fits naturally into MimIR, you can often encode it directly as Defs and store it in the analysis lattice.
An Analysis visits:
During the first part, mim::Analysis::is_bootstrapping() is true. During the second part, it is false.
Typical usage:
The lattice follows these conventions:
Analysis provides the following accessors and mutators:
Analysis-specific sentinels should be ordinary Defs - e.g. a dedicated Proxy tag, as SEO uses for its GVN and pending-⊤ markers - never nullptr, which is reserved for absent.
Unlike RWPhase, an Analysis must traverse the entire reachable program without rebuilding it. For this reason, Analysis overrides rewrite_mut() to keep mutables in place and use the rewriter machinery as a graph-aware traversal over the existing world.
Immutables are still visited depth-first through the inherited Rewriter recursion, but mutables are visited breadth-first via an internal worklist. This matters because abstract values typically flow between mutables — e.g. from an App call site into the callee's binder vars. A breadth-first order tends to seed a mutable from all of its predecessors before its body is walked, so information propagates further per fixed-point round and convergence needs fewer rounds. Breadth-first traversal is safe here precisely because an Analysis never rebuilds a mutable: it maps every mutable to itself, so nothing depends on a mutable being fully rewritten before it is used (contrast the strict depth-first ordering an RWPhase needs, where a rebuilt binder's type/identity is consumed as it is constructed).
Once a batch of roots has been scheduled, Analysis::drain() pops mutables from the worklist and, for each, enters it for curr_mut() tracking and rewrites its dependencies. Rewriting those dependencies schedules any further mutables it reaches, so the worklist drains in breadth-first order.
The mut -> mut entry recorded in step 2 doubles as the per-round "already scheduled" marker: it lives in the rewriter map (see lookup()), which reset() clears at the start of every round. Hence each mutable's dependencies are walked at most once per fixed-point round, which also prevents cyclic (recursive) CFGs from recursing forever.
When a rewrite_imm_App override propagates abstract values from call arguments into a callee's binder vars, it should seed those lattice entries first and then rewrite() the callee. This schedules the callee (or does nothing if it is already scheduled), so its body is walked later during the drain, after the seeded facts — and any joins from sibling call sites — are in place. lattice(concr, abstr) conveniently pairs the two writes (lattice and rewriter map) that arise in this seeding pattern.
A full round traverses the whole World: start() first runs prepare(), then rewrites all annex roots, drains the worklist, does the same for the external mutables, and finally runs finalize(). Whenever lattice(concr, abstr) changes an entry it invalidates, requesting another round, and records curr_mut() as dirty.
The first round, and each certification round described below, is full. A follow-up round is sparse: it re-drains only dirty mutables — plus everything reachable from them — instead of walking the whole World. At the start of a sparse round the accumulated lattice is replayed into the rewriter map, so a dirty mutable's body sees the substitutions its (non-revisited) producers installed in earlier rounds. An analysis can taint() additional mutables when a change must re-visit more than the writer — e.g. SEO taints all call sites of a Lam whose abstract vars changed, which keeps its per-round join restart sound. A change that cannot be attributed to any mutable (during the annex walk or finalize()) forces the next round to be full.
Since dirt tracks writers — not readers — a sparse round may miss affected mutables. Hence, once sparse rounds quiesce, one final full round certifies the fixed point; if it discovers new facts, iteration continues sparsely from its dirt. Only full rounds run finalize(), so post-passes always see the complete abstract World. Use make_dense() to force whole-World rounds unconditionally.
If an analysis participates in a fixed-point loop, it should be ready to run multiple times. The base reset() clears the rewriter map (and hence the per-round "already scheduled" markers) and the worklist, and resets Phase::todo() for the next round, but preserves lattice() so that abstract values accumulated in earlier iterations remain available — this is what makes fixed-point convergence possible.
RWBase is the common base of the two rewriting phases: RWPhase rebuilds the world into a new one, InplaceRWPhase stays in the current one. Both are a Phase and a Rewriter and share the skeleton described here.
You never derive from RWBase directly — pick one of the two.
An RWBase may be given an associated Analysis. If so, analyze() runs that analysis to a fixed point before rewriting begins.
This is a common pattern:
If no analysis is needed, analyze() can simply return false.
Once analyze() has run, the rewrite can query the analysis result through RWBase::lattice().
RWBase::lattice() provides access to the analysis lattice for the Defs that the analysis visited — the old ones in the case of an RWPhase. It returns the abstract value computed by the associated Analysis, or nullptr if no value is available.
This is the standard way to communicate fixed-point analysis results into the subsequent rewrite.
Like Analysis, an RWBase processes annex roots before externals.
While annexes are being rewritten, mim::RWBase::is_bootstrapping() is true.
This matters because annexes may depend on one another. During bootstrapping, a rewrite that refers to another annex may need to defer or skip that work because the referenced annex might not yet exist in the new world.
rewrite_annexes() decides whether the annex roots are walked at all. An RWPhase has to walk them in order to populate the new world's annex table, so it returns true; an InplaceRWPhase finds that table already correct and defaults to false.
rewrite_root() is the hook for rewrites that must exempt roots. An annex or an external is the program's interface to the outside, so a phase that reshapes definitions usually has to leave the roots themselves alone; EtaConv uses it so that an annex or external keeps its η-shape.
A rewrite hook may not be able to finish its work immediately. For example, a closure-converting phase must create a function stub eagerly so callers can refer to it, but can rewrite its body only after completing the enclosing scope. Push such work onto your own worklist and drain it in finalize(), which runs after all roots have been walked but — for an RWPhase — still before the two worlds are swapped (see clos::phase::ClosConv).
RWPhase is the base class for phases that rebuild the current world into a new one, thereby removing garbage. This is the standard base class for optimization phases that structurally transform IR.
Here the RWBase's two worlds differ:
After the final swap, the rewritten world becomes the current one.
Cleanup is simply an RWPhase with no custom rewrites. Because an RWPhase reconstructs only what is reachable from the world roots, rebuilding automatically eliminates dead and unreachable code.
Run it with:
InplaceRWPhase rewrites the current world in place instead of rebuilding it into a fresh one.
A mutable keeps its identity: its ops() are reset with Def::set only when rewriting changes them. So hash-consing makes every unaffected Def free instead of a per-run rebuild tax. A mutable whose type changes is the one exception — identity is tied to the type — and falls back to an RWPhase-style stub rebuild in this same world. This matters most for the annex graph: it is proportional to the loaded plugins — not to the program — and a local rewrite never touches it, yet an RWPhase re-creates all of it on every run.
Unlike an RWPhase, the RWBase's two worlds are the same one here, so plain world() is what you want.
Use an RWPhase for anything else.
Since nothing is rebuilt, an InplaceRWPhase only pays for the nodes it actually looks at. So prune whatever provably cannot change with a cheap O(1) test at the top of your rewrite(); this is what turns the traversal from "hash-cons every node" into "touch only what matters".
Def::is_ground is the ready-made test for phases that only rewrite mutables and/or substitute Vars: a subtree with neither local_muts() nor local_vars() contains neither. Both BetaRed and EtaConv use it.
By default, an InplaceRWPhase walks only the external roots: whatever the program actually uses is reached through the externals anyway. Override rewrite_annexes() with true if your rewrite must also see unused annexes.
Since a change is only ever committed if it really is one, Phase::todo() is exact: a quiet run costs a pruned traversal and nothing else. This is what makes an InplaceRWPhase cheap to re-run inside a PhaseMan fixed-point loop.
PhaseMan organizes several phases into a pipeline.
It can run them:
A fixed-point PhaseMan reruns the pipeline as long as at least one phase invalidates. Because a phase's run is a deterministic function of the World's content, PhaseMan skips a phase when its last run was quiet and no later phase has changed the world. Consequently, tail iterations rerun only phases that may still make progress; the pipeline terminates without a final round that reruns every phase.
Before a rerun, the phase is recreated from its original configuration. This prevents phase-local state from leaking across rounds unless the phase explicitly reconstructs it.
Use a fixed-point pipeline when phases expose new optimization opportunities for one another.
ClosedMutPhase is a traversal helper for phases that visit all reachable, closed mutables in the world.
A mutable is relevant here if it is:
This is useful for local analyses or transformations naturally phrased as:
You override visit(M*), where M defaults to Def but may be restricted to a particular mutable subtype.
NestPhase builds on ClosedMutPhase and computes a Nest for each visited mutable.
Use it when your phase needs a structured view of nested control or binding structure rather than just the raw mutable.
Instead of overriding visit(M*), override:
This is convenient for analyses that reason about nesting, dominance-like structure, or hierarchical regions.
The provided Sparse Conditional Constant Propagation (SCCP) implementation is a good example of the intended phase structure. Its architecture is:
The SCCP analysis associates each lambda variable with a lattice value:
In the implementation, this lattice is stored in Analysis::lattice() as a Def2Def map. A nice aspect here is that the propagated value is itself a regular Def. This illustrates the benefit of building analysis on top of Rewriter: the abstract domain can live directly inside MimIR, so canonicalization and normalization come for free.
The join in propagate() is expressed entirely through the lattice API: lattice(var) reads the current abstract value, lattice(concr, abstr) overwrites it, and pin() resolves conflicting values to ⊤. No manual invalidate() bookkeeping is needed: every join step that gains information - including the ⊥ → value insert - triggers the next fixed-point round automatically via lattice(concr, abstr).
The analysis traverses the old world and updates the lattice when it sees applications of optimizable lambdas. Whenever this changes the lattice, the analysis reruns until stable - sparsely, re-draining only the dirty mutables in between full rounds. This is a textbook use of Analysis:
The very first line of propagate() is a guard that has no counterpart in the lattice algebra:
It is the MimIR analogue of the dominance side condition that a classical SSA-based SCCP has to enforce, so it is worth spelling out what it replaces.
Textbook SCCP only ever propagates constants. A constant is a literal: it has no operands and is available at every program point by construction. This is what makes classical SCCP so comfortable — specializing a call site to a constant is always valid, and there is simply no availability question to ask.
MimIR's SCCP is more ambitious: it propagates arbitrary expressions, not just constants (this is essentially copy/expression propagation folded into the same fixed point). The moment you propagate a whole expression, you inherit an obligation constants let you ignore: the expression you substitute must actually be available at the point where it lands. The guard is exactly that availability check.
Textbook SCCP runs on a CFG in SSA form. Every value has exactly one definition, control flow is made explicit by basic blocks and edges, and φ-nodes reconcile the values that arrive along the different predecessor edges of a join block. SCCP assigns each SSA value a lattice cell (⊥ / a constant / ⊤) and, once the fixed point is reached, substitutes the discovered constant at every use of that value.
That substitution is only sound because SSA comes with a dominator tree:
Dominance is exactly the structural guarantee "the value already exists at the program point where I want to use it". Without it, folding a value into a use could move a computation to a place where its operands are not yet defined.
MimIR has no CFG, no basic blocks, and no separate φ instructions. Control flow is expressed in CPS: a Lam is a basic block, its parameters are the φ-nodes, and every App of that Lam is one predecessor edge supplying the corresponding operands. So the SCCP analysis joins, per parameter, all the arguments flowing in from the call sites — precisely the φ-semantics — and stores the result in the lattice().
What is missing is the dominator tree. Its role — deciding whether a candidate value is available at the point where it would be substituted — is taken over by the scope/nesting relation Def::nests, computed structurally from free variables rather than from a precomputed CFG analysis. L->nests(def) holds iff def lives strictly inside L, i.e. it transitively depends on binders introduced below L; a def that only mentions things visible at L's level or further out is not nested.
Now the guard reads directly:
In other words, where classical SCCP walks a dominator tree to certify availability, MimIR asks a single scope question: is this value visible at the binder it would replace? Def::nests is that availability oracle, and it falls straight out of the free-variable structure that MimIR maintains anyway — no auxiliary dominance computation required.
The availability obligation is cheap to state but awkward to discharge in most IRs, and this is where MimIR's structural answer stands out.
MimIR sidesteps the dilemma without introducing syntactic scope: it is a scopeless IR. There are no lexical scoping brackets that a Lam opens over its body; instead, scope is implicit, emerging from how free variables nest. Def::nests reads availability straight off that implicit nesting — L nests def iff def transitively depends on a variable bound below L — so the containment a lexical language would spell out with explicit brackets is recovered purely from the free-variable structure MimIR maintains anyway. The query is structural and commits to no schedule, so MimIR gets a compelling, schedule-free answer to the availability question that dominance-based and sea-of-nodes IRs can only reconstruct by (partially) scheduling first.
Once the lattice is stable, the outer SCCP phase starts rewriting. During rewriting, it can query abstract values for old-world definitions through RWBase::lattice().
When it sees an application of a lambda whose parameters have propagated values, it rebuilds a specialized lambda:
So SCCP follows the standard RWPhase pattern:
Separating SCCP into analysis and rewrite keeps both parts simple:
This separation is the main design pattern to follow for nontrivial optimizations.
A useful rule of thumb is:
For most optimization phases, the preferred structure is:
This keeps analyses and transformations cleanly separated and fits naturally with MimIR's rewriting-based infrastructure.
A simple whole-world rewrite phase:
A simple analysis phase:
Using both:
You can also expose your custom phases as axioms in Mim via the compile plugin and build your own compilation pipeline. Mim's default compilation pipeline is defined in the opt plugin.
Phases are MimIR's main unit of compiler work.
The key design idea is that MimIR phases are built around structured traversal and rewriting. For substantial optimizations, the usual pattern is: