MimIR
MimIR is my Intermediate Representation
Loading...
Searching...
No Matches
Phases

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.

Overview

A Phase has a single entry point, run(), which wraps the actual implementation in start().

Phase

Phase is the minimal base class.

A phase provides:

  • a name or annex,
  • access to the current World,
  • a run() wrapper for logging and verification,
  • a todo() accessor backed by the internal todo_ flag for fixed-point iteration.
Note
A phase requests another round by calling invalidate().
  • PhaseMan uses this to drive fixed-point pipelines.
  • RWPhase uses this to drive its optional pre-analysis to a fixed point.

Typical Shape

A custom phase usually derives from Phase and implements start():

class MyPhase : public mim::Phase {
public:
MyPhase(World& world)
: Phase(world, "my_phase") {}
private:
void start() override {
// do work here
}
};
A Phase performs one self-contained task over the whole World.
Definition phase.h:23

Run it with:

virtual void run()
Entry point and generates some debug output; invokes Phase::start.
Definition phase.cpp:51

Analysis

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.

Note
An Analysis based on Rewriter has an abstract domain of ordinary 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:

  • hash-consing / canonical sharing,
  • built-in normalizations,
  • and other simplifications already provided by the World.

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 everything reachable from the external mutables. Axioms and mutable annexes other than Lams are opaque; see Annexes.

Typical usage:

Lattice API

The lattice follows these conventions:

  • An absent entry means ⊥ - nothing is known yet.
  • An entry mapping a definition to itself (def ↦ def) means ⊤ - "no useful information, keep as-is".
  • Any other entry is a discovered abstract value. Analyses may also introduce intermediate sentinels as ordinary Defs (for example, SEO's GVN-bundle and pending-⊤ proxies).

Analysis provides the following accessors and mutators:

  • lattice() returns the full lattice map.
  • lattice(def) returns the recorded abstract value for def, or nullptr if nothing is known.
  • lattice(concr, abstr) writes concr ↦ abstr into both the lattice and the rewriter map (so future rewrites of concr short-circuit to abstr) and automatically invalidates iff this changes observable information: an existing entry was overwritten, or a fresh fact other than ⊤ was inserted. Freshly inserting ⊤ (def ↦ def) stays silent, as it is indistinguishable from an absent entry for consumers. It returns true iff it changed observable information - i.e. iff it invalidated - so the caller can still react, e.g. log. Besides recording a lattice fact, it also seeds the rewriter map, so a later rewrite() of concr immediately returns abstr. It asserts if you descend from ⊤.
  • lattice_force(concr, abstr) is the non-asserting variant.
  • pin(def) monotonically forces def to ⊤. Being built on lattice(concr, abstr), it invalidates iff it overwrote previous information.
  • is_top(def) checks for def ↦ def.

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.

Handling of Mutables

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).

rewrite_mut():

  1. returns immediately if the mutable was already scheduled this round (see below),
  2. records the mutable as visited via mut -> mut, and
  3. enqueues it on the worklist — it does not recurse into the body itself.

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.

Warning
Because rewrite_mut() enqueues instead of dispatching by node, the node-specific rewrite_mut_* hooks (e.g. rewrite_mut_Lam) are never invoked for an Analysis. Override rewrite_mut() itself (or the rewrite_imm_* hooks, which dispatch as usual) instead.

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.

Sparse Fixed-Point Iteration

A full round traverses the whole World: start() first runs prepare(), then rewrites all external mutables, drains the worklist, 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 (e.g. in 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.

Reset Between Iterations

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.

RWPhase

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. It is both a Phase and a Rewriter:

Note
To avoid confusion, direct world() access is deleted. Use:

Execution Model

  1. optionally perform a fixed-point analysis with analyze(),
  2. rewrite the external mutables of the old world;
  3. run finalize();
  4. swap the old and the new world.

After the final swap, the rewritten world becomes the current one.

Optional Pre-Analysis

An RWPhase 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.

Analysis Results

Once analyze() has run, the rewrite can query the analysis result through RWPhase::lattice().

RWPhase::lattice() provides access to the analysis lattice for old-world Defs. 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.

Annexes

optimize() moves all annexes into the Driver::library(); a World imports an annex on first use. An RWPhase does not rewrite an axiom or a mutable annex but resolves it to the annex with the same flags in the new world, which imports it unchanged on demand. So a phase never alters such an annex - its shape is what normalizers and backends rely on. An immutable annex is just its value and is rewritten like any other immutable.

The exception is an annex Lam: it is code, so it passes through the phase like the rest of the program - as an ordinary Lam. A phase that wants to process a Lam it fetches itself - e.g. a priv *_impl it instantiates - builds the instance in old_world() and rewrites it (see tensor::phase::Lower).

Externals

rewrite_external() is the hook for rewrites that must exempt the externals. An external is the program's interface to the outside, so a phase that reshapes definitions usually has to leave the externals themselves alone; EtaConv uses it so that an external keeps its η-shape.

Deferred Rewrites

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 still before the two worlds are swapped (see clos::phase::ClosConv).

Cleanup

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.

Reshaping Aggregates

A phase that drops or reorders the components of a mutable Sigma changes its number of ops, so it needs the index-mapping overload of rewrite_stub together with Sieve - see Reshaping Aggregates in the Rewriting Guide. SingleErasure is the canonical user.

Sieve is useful on its own, though: dropping the parameters of a Lam changes its type rather than its ops, so the SCCP transformation below only uses gather and operator[].

Coercion

An RWPhase may change how values are represented - Scalarize flattens tuple parameters, SingleErasure drops information-free components. Such a phase cannot reshape everything: an Axm's signature is fixed, since normalizers and backends match on it, and an interface Lam (an external, an annex, or a foreign declaration) has callers outside the phase's reach.

So wherever a type is checked, every RWPhase coerce()s the rewritten value to the rewritten expected type:

  • an App's argument to the callee's domain, and its result to the App's type,
  • a Tuple's ops to the components of its annotated type - an inferred one just follows the rewritten ops,
  • a Lam's body to its codomain.

Coercion matches value and type leaf by leaf: it explodes and reassembles tuples, η-wraps a function whose Pi differs, and drops or fills in components that have a sole inhabitant. Where nothing was reshaped, value and type are already assignable and coercion is the identity.

This lets the phase decide its representation per type instead of first pinning every shape that could meet a fixed one in an Analysis. To keep a shape fixed by its position, rewrite it with a nested Rewriter: Scalarize rewrites everything an interface Lam's signature mentions with its Keep rewriter, where no Pi gets flattened. The very same Pi elsewhere is flattened in context 0, and coercion bridges both wherever they meet.

Typical Shape

class MyRWPhase : public mim::RWPhase {
public:
MyRWPhase(World& world)
: RWPhase(world, "my_rw_phase") {}
private:
const Def* rewrite_imm_App(const App* app) override {
// customize rebuilding here
}
};
Rebuilds old_world() into new_world() and then swaps them.
Definition phase.h:330
const Def * rewrite_imm_App(const App *) override
Definition phase.cpp:223

Run it with:

PhaseMan

PhaseMan organizes several phases into a pipeline.

It can run them:

  • once, in sequence, or
  • repeatedly to a fixed point.

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.

Note
PhaseMan is the orchestration layer for classical phase pipelines.

Typical Shape

auto phases = mim::Phases();
phases.emplace_back(std::make_unique<PhaseA>(world));
phases.emplace_back(std::make_unique<PhaseB>(world));
man.apply(/*fixed_point=*/true, std::move(phases));
man.run();
Organizes several Phases into a pipeline.
Definition phase.h:479
std::deque< std::unique_ptr< Phase > > Phases
Definition phase.h:18
static consteval flags_t base()
Definition plugin.h:276

Use a fixed-point pipeline when phases expose new optimization opportunities for one another.

ClosedMutPhase

ClosedMutPhase is a traversal helper for phases that visit all reachable, closed mutables in the world.

A mutable is relevant here if it is:

  • reachable,
  • closed, i.e. it has no free variables,
  • optionally non-empty, depending on elide_empty.

This is useful for local analyses or transformations naturally phrased as:

Note
For every reachable closed mutable, inspect or process it.

You override visit(M*), where M defaults to Def but may be restricted to a particular mutable subtype.

Typical Shape

class MyClosedPhase : public mim::ClosedMutPhase<Lam> {
public:
MyClosedPhase(World& world)
: ClosedMutPhase(world, "my_closed_phase", /*elide_empty=*/true) {}
private:
void visit(Lam* lam) override {
// process each reachable closed Lam
}
};
Transitively visits all reachable, closed mutables in the World.
Definition phase.h:511

NestPhase

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:

visit(const Nest&)

This is convenient for analyses that reason about nesting, dominance-like structure, or hierarchical regions.

Example: SCCP

#pragma once
#include "mim/phase.h"
namespace mim {
/// SCCP - Sparse Conditional Constant Propagation - but propagates **arbitrary expressions**.
/// @see [Constant propagation with conditional branches](https://dl.acm.org/doi/pdf/10.1145/103135.103136)
///
/// Lattice per Lam::var:
/// ```
/// ⊤ ← Keep as is
/// |
/// Expr ← Whole expression is propagated (vertically) through var
/// |
/// ⊥
/// ```
/// @pre EtaConv ran beforehand, so no rewritable Lam escapes as a value.
/// Otherwise, the vars of such unknown functions would have to be pinned to ⊤.
class SCCP : public RWPhase {
private:
class Analysis : public mim::Analysis {
public:
Analysis(World& world)
: mim::Analysis(world, "SCCP::Analyzer") {}
private:
const Def* propagate(Lam* lam, const Def* var, const Def* def);
const Def* rewrite_imm_App(const App* app) final;
};
public:
: RWPhase(world, annex, &analysis_)
, analysis_(world) {}
private:
const Def* rewrite_imm_App(const App* old_app) final;
Analysis analysis_;
Lam2Lam lam2lam_;
};
} // namespace mim
Traverses the current World using Rewriter infrastructure while staying in the same world.
Definition phase.h:130
World & world()
Definition phase.h:58
friend class Analysis
Definition phase.h:106
flags_t annex() const
Definition phase.h:62
World & world()
Definition phase.h:58
RWPhase(World &world, std::string name, Analysis *analysis=nullptr)
Definition phase.h:334
SCCP(World &world, flags_t annex)
Definition sccp.h:33
const Def * rewrite_imm_App(const App *old_app) final
Definition ast.h:16
u64 flags_t
Definition types.h:39
LamMap< Lam * > Lam2Lam
Definition lam.h:212

The provided Sparse Conditional Constant Propagation (SCCP) implementation is a good example of the intended phase structure. Its architecture is:

  • an inner Analysis computes propagation facts on the old world,
  • an outer RWPhase uses those facts to rebuild a simplified new world.
Note
The implementation propagates not only constants but also arbitrary expressions.

Analysis

#include "mim/phase/sccp.h"
namespace mim {
const Def* SCCP::Analysis::propagate(Lam* lam, const Def* var, const Def* def) {
// `⊥ ⊔ x` is `x`, but unusable if lam nests it.
if (lam->nests(def)) return pin(var), var;
auto cur = lattice(var);
if (!cur) { // ⊥ ⊔ def = def; lattice(var, def) invalidates, as it inserts a fresh non-⊤ fact
lattice(var, def);
log().d("propagate {} → {}", var, def);
return def;
}
if (cur == var) return cur; // ⊤ stays ⊤
// cur ⊔ ⊥ = cur, but the ⊥-site's caller must see cur as well.
if (def->isa<Bot>()) {
if (auto caller = curr_mut(); caller && !cur->in_scope(caller)) return pin(var), var;
return cur;
}
if (cur == def) return cur; // cur ⊔ cur = cur
if (cur->isa<Bot>()) { // ⊥ ⊔ def = def; lattice(var, def) invalidates, as it overwrites cur
lattice(var, def);
return def;
}
return pin(var), var; // two different values join to ⊤; lattice(var, var) therein invalidates, as it overwrites cur
}
const Def* SCCP::Analysis::rewrite_imm_App(const App* app) {
if (auto lam = Lam::isa_rewritable(app->callee())) {
auto n = app->num_targs();
auto abstr_args = DefVec(n);
auto abstr_vars = DefVec(n);
// propagate
for (size_t i = 0; i != n; ++i) {
auto abstr = rewrite(app->targ(i));
abstr_vars[i] = propagate(lam, lam->tvar(i), abstr);
abstr_args[i] = abstr;
}
lattice(lam->var(), world().tuple(abstr_vars)); // set new abstract var
return world().raw_app(rewrite(app->type()), rewrite(lam), world().tuple(abstr_args));
}
}
} // namespace mim
const auto & lattice() const
The whole map; used e.g. to diff two fixed-point runs.
Definition phase.h:165
bool pin(const Def *def)
Monotonically forces def to ⊤ (keep as is).
Definition phase.h:227
static Lam * isa_rewritable(const Def *def)
Yields def as a mutable Lam, if it is_rewritable() and nullptr otherwise.
Definition lam.h:193
const fe::Log & log() const
Definition phase.h:60
const Def * lattice(const Def *old_def) const
Returns the abstract value computed by the associated Analysis for the given old-world Def,...
Definition phase.h:347
D * curr_mut() const
Definition rewrite.h:98
virtual const Def * rewrite(const Def *)
Definition rewrite.cpp:62
virtual const Def * rewrite_imm_App(const App *)
const Def * raw_app(const Axm *axm, u8 curry, u8 trip, const Def *type, const Def *callee, const Def *arg)
Definition world.cpp:501
fe::Vector< const Def * > DefVec
Definition def.h:98
TExt< false > Bot
Definition union.h:39

The SCCP analysis associates each lambda variable with a lattice value:

  • bottom: no useful information yet (an absent entry),
  • a concrete expression: this value can be propagated,
  • top: keep the variable as-is (a Def maps to itself).

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 conditional part of SCCP needs no code of its own. Wegman and Zadeck track which CFG edges are executable, so that an unreachable predecessor does not spoil a join. Here, the analysis rewrites each body into the abstract domain, and building that abstract body normalizes it. Once the condition of a branch (f, t)#c is abstracted to a literal, the extract folds to the taken target, and rewrite_imm_Extract only rewrites that op. The untaken target is never scheduled, so its call sites never contribute to a join. This works for any condition that normalizes to a literal - including one computed from a propagated expression rather than a plain constant.

Transformation

#include "mim/phase/sccp.h"
namespace mim {
const Def* SCCP::rewrite_imm_App(const App* old_app) {
if (auto old_lam = old_app->callee()->isa_mut<Lam>()) {
if (abstracted(old_lam->var())) {
auto old_vars = old_lam->tvars();
auto keep = Sieve(old_vars, [this](const Def* var) { return is_top(var); });
auto new_lam = fe::lookup(lam2lam_, old_lam);
if (!new_lam) {
auto new_doms = rewrite(keep.gather(old_lam->tdoms()));
new_lam = new_world().mut_lam(new_doms, rewrite(old_lam->codom()))->set(old_lam->dbg());
lam2lam_[old_lam] = new_lam;
auto new_vars = DefVec(keep.num_old(), [&](size_t i) {
auto j = keep[i];
return j == Sieve::Gone ? rewrite(lattice(old_vars[i])) // SCCP propagate
: new_lam->var(keep.num_new(), j);
});
map(old_lam->var(), new_vars);
new_lam->set(rewrite(old_lam->filter()), rewrite(old_lam->body()));
}
return new_world().app(new_lam, rewrite(keep.gather(old_app->targs())));
}
}
return RWPhase::rewrite_imm_App(old_app);
}
} // namespace mim
Lam * set(Filter filter, const Def *body)
Definition lam.cpp:34
void invalidate(bool todo=true)
Signals that another round of fixed-point iteration is required, either as part of.
Definition phase.h:79
World & new_world()
Create new Defs into this.
Definition phase.h:379
const Def * abstracted(const Def *old_def) const
Returns lattice(old_def) if it differs from old_def (i.e. we learned something), otherwise nullptr.
Definition phase.h:350
bool is_top(const Def *old_def) const
Definition phase.h:355
const Def * map(const Def *old_def, const Def *new_def)
Definition rewrite.cpp:29
Sieve(size_t num)
Definition tuple.h:123
const Def * app(const Def *callee, const Def *arg)
Definition world.cpp:416
Lam * mut_lam(const Pi *pi)
Definition world.h:415

Once the lattice is stable, the outer SCCP phase starts rewriting. During rewriting, it can query abstract values for old-world definitions through RWPhase::lattice().

When it sees an application of a lambda whose parameters have propagated values, it rebuilds a specialized lambda:

  • parameters with known propagated expressions are removed,
  • remaining parameters are kept,
  • the lambda body is rewritten with the propagated values substituted,
  • the call site is rebuilt with only the remaining arguments.

Which parameters survive is stated once as a Sieve over the old Vars, and all three rebuilds read it back: gather picks the surviving domains and arguments, while operator[] maps each old parameter either to its new Var or - on Gone - to its propagated value.

The invalidate() in the transformation is unrelated to the analysis' fixed point. It tells an enclosing fixed-point PhaseMan that this phase changed the program, so the other phases get another round.

So SCCP follows the standard RWPhase pattern:

  1. analyze the old world,
  2. rewrite into a new world using the computed facts,
  3. swap worlds.

Discussion

Separating SCCP into analysis and rewrite keeps both parts simple:

  • the analysis never mutates or partially rewrites the program,
  • the rewrite does not need to discover facts on the fly,
  • fixed-point logic stays in the analysis stage where it belongs,
  • the handoff from analysis to rewrite is explicit through Analysis::lattice() and RWPhase::lattice().

This separation is the main design pattern to follow for nontrivial optimizations.

Note
The complete SCCP example is less than 100 lines of C++ source code. Most of the usual compiler boilerplate is absorbed by the existing Analysis, RWPhase, and Rewriter infrastructure, so the implementation can focus on the optimization itself.

SSA without Dominance

The very first line of propagate() is a guard that has no counterpart in the lattice algebra; a second one guards the ⊥ case of the join:

if (lam->nests(def)) return pin(var), var;

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.

Why the guard exists at all

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.

The classical picture

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:

  • a definition dominates all of its uses, and
  • a φ-operand must be available along its associated predecessor edge, i.e. its definition dominates the end of that predecessor block.

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.

The MimIR picture

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:

  • L is the lam passed to propagate(), var is one of its parameters, and the joined value replaces var throughout L's body.
  • If L->nests(def), the joined value refers to binders that only come into existence within L's own body, so only a call site inside L — a recursive jump — can pass it. Substituting it for var would use those binders before they exist: on entry to L, nothing below L has been bound yet. This is the exact situation dominance forbids, so the analysis pins var to ⊤ (pin) instead.
  • A call site outside L cannot pass such a value: it passes ⊥, which the ⊥-site check handles, or a different value, which joins to ⊤ anyway. But when L is only entered through an escaping value, its recursive call sites are the only known ones, and this guard is all that stands in the way.

In other words, where classical SCCP walks a dominator tree to certify availability, MimIR asks scope questions: is this value visible at the binder it would replace, and at every caller? Def::nests and Def::in_scope are those availability oracles, and they fall straight out of the free-variable structure that MimIR maintains anyway — no auxiliary dominance computation required.

⊥-sites

L->nests(def) only rules out values from below L. A value from a sibling scope passes it, and the join alone never notices when that is wrong:

(init, early)#c () where
con early () = exit ⊥:Nat;
con init () =
ret y = get $ ();
exit y;
con exit (v: Nat) = ...;
end;

y is bound by init's continuation, which does not nest exit, so exit happily joins v ↦ y. early passes ⊥, and y ⊔ ⊥ = y — but ⊥ is a contribution that says nothing, not a promise to be compatible with whatever the other sites send. Once y replaces v, exit's body mentions y, so exit now lives inside init's continuation — and early, outside of it, can no longer jump there.

So propagate() checks a ⊥-site, too: the joined value must be in scope at the calling mutable, i.e. cur->in_scope(caller) — every free Var of cur is bound by caller itself or by a mutable that nests caller. Otherwise var is pinned to ⊤. In dominance terms: the definition must dominate every predecessor edge, including the ones that carry no information.

The check needs no ordering: if the ⊥-site happens to be visited before cur arrives, the lattice change triggers another round, and the full round that certifies the fixed point revisits the ⊥-site with the final cur. mem.seo performs the very same ⊥-site check; there, it also catches a φ of an unrelated loop header flowing into a block that a ⊥-site jumps to as well.

Why this is hard elsewhere

The availability obligation is cheap to state but awkward to discharge in most IRs, and this is where MimIR's structural answer stands out.

  • CFG + SSA answers it with the dominator tree, as sketched above. This works, but only because the CFG has already fixed where every value lives; the whole machinery presupposes a schedule - which may overapproximate actual data dependencies.
  • Sea-of-nodes deliberately refuses that commitment: data nodes float, and only control, φ, and memory nodes are pinned. That freedom is the entire point — it is what lets the optimizer move computations around without fighting a premature schedule. But it makes availability ill-posed: a floating expression has no location, so "is it available here?" is not even a well-formed question until the node is anchored. To answer it you must reason about where the expression's transitively control-pinned inputs would sit — that is, run (at least partial) global code motion and consult the CFG dominator relation. So copy/expression propagation drags the schedule — precisely what sea-of-nodes set out to avoid — back into the picture.

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.

A second dominance: contification

The availability guard is not the only place where a classical dominance computation fails to materialize.

Contification turns a function that always returns to the same place into a continuation. Here is the canonical example of Fluet & Weeks, transcribed into Mim with their original in the comment.

plugin core;
plugin mem;
use core.ops.n;
// fun g y = y - 1
// fun f b = (if b then g 13 else g 15) + 1
extern fun f (b: Bool): Nat =
(l2, l1)#b () where
con l1 () = g (13, k);
con l2 () = g (15, k);
fun g (y: Nat): Nat = return (y - 1);
con k (x: Nat) = return (x + 1);
end;

Both calls to g return to the same continuation k, so g can become a continuation inside f. Their algorithm computes where the contified function goes from the dominator tree of the program's call graph, and proves that choice optimal within their framework. That placement is a real decision with a wrong answer: a continuation may only be jumped to from within its scope, so putting g too far in hides it from some of its call sites, and too far out is not expressible at all.

MimIR never makes the decision. A return continuation is an ordinary parameter, so the join described above already covers it: once every call site passes the same continuation, that parameter is propagated away and g's body transfers control straight to it. Contification is not a separate transformation here — it is what expression propagation looks like when the propagated expression happens to be a Cn.

Placement then takes care of itself, because where a Lam sits is a function of its free variables and not of anything a phase decides. Before propagation g is closed, so it belongs to the top level even when it was written inside f. After propagation g mentions f's continuation, so it belongs inside f. Neither move is carried out by anything; both are readings of the very free-variable structure that Def::nests already queries for availability.

The contrast with a scoped compiler is sharp. MLton runs contification after closure conversion, and Fluet & Weeks describe their own loop example as moving loop back inside sum after closure conversion took it out — a dominator analysis reconstructing a nesting that an earlier phase destroyed. A scopeless IR never loses that nesting, so there is no hoisting-versus-contification phase ordering to get right and no oracle to consult.

Choosing the Right Base Class

A useful rule of thumb is:

  • derive from Phase if you just need a custom one-off action,
  • derive from Analysis if you want a graph-aware traversal that computes facts on the current world,
  • derive from RWPhase if you want to rebuild the world into a transformed new one, optionally consuming facts from an associated Analysis,
  • derive from ClosedMutPhase if you want to visit all reachable closed mutables,
  • derive from NestPhase if that visit should come with a computed Nest.

Recommended Design Pattern

For most optimization phases, the preferred structure is:

  1. write an Analysis that computes facts to a fixed point,
  2. store those facts in Analysis::lattice() and/or auxiliary tables,
  3. write an RWPhase that consumes those facts while rebuilding the world.

This keeps analyses and transformations cleanly separated and fits naturally with MimIR's rewriting-based infrastructure.

Minimal Examples

A simple whole-world rewrite phase:

class Simplify : public mim::RWPhase {
public:
Simplify(mim::World& world)
: RWPhase(world, "simplify") {}
private:
const mim::Def* rewrite_imm_App(const mim::App* app) override {
// rewrite or simplify selected applications
// fallback:
}
};
Base class for all Defs.
Definition def.h:289
The World represents the whole program and manages creation of MimIR nodes (Defs).
Definition world.h:43

A simple analysis phase:

class CountMutLams : public mim::Analysis {
public:
CountMutLams(mim::World& world)
: Analysis(world, "count_lams") {}
size_t num_lams = 0;
private:
// Note: override rewrite_mut - the node-specific rewrite_mut_* hooks are not dispatched
// for an Analysis (see "Handling of Mutables").
mim::Def* rewrite_mut(mim::Def* mut) override {
if (!lookup(mut) && mut->isa_mut<mim::Lam>()) ++num_lams; // count on first visit only
}
};
const Def * rewrite_mut(Def *) final
Schedules mut for a breadth-first visit of its dependencies and records mut -> mut.
Definition phase.cpp:159
T * isa_mut() const
If this is mutable, it will cast constness away and perform a dynamic_cast to T.
Definition def.h:588
A function.
Definition lam.h:91

Using both:

CountMutLams analysis(world);
analysis.run();

Compilation Pipelines in M⁠im

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.

Summary

Phases are MimIR's main unit of compiler work.

  • Phase is the minimal base abstraction.
  • Analysis is for graph-aware fact collection on the current world and provides a reusable lattice() for abstract values.
  • RWPhase is for rewriting the current world into a transformed new one and can read analysis results through RWPhase::lattice(). It coerces reshaped values wherever they meet a fixed shape.
  • PhaseMan sequences phases, optionally to a fixed point.
  • ClosedMutPhase and NestPhase are traversal helpers for common whole-world inspections.

The key design idea is that MimIR phases are built around structured traversal and rewriting. For substantial optimizations, the usual pattern is:

Previous Next
Rewriting Python Bindings