MimIR
MimIR is my Intermediate Representation
Loading...
Searching...
No Matches
Developer Guide

This guide summarizes the typical idioms and patterns you will want to use when working with MimIR as a developer. It focuses on the C++ API and IR-building workflow.

Basics

Let's jump straight into an example.

#include <mim/driver.h>
#include <mim/ast/parser.h>
#include <mim/util/sys.h>
using namespace mim;
using namespace mim::plug;
int main(int, char**) {
try {
auto driver = Driver("hello");
auto& w = driver.world();
driver.log().set(&std::cerr).set(Log::Level::Debug);
// Cn [%mem.M 0, I32, %mem.Ptr (I32, 0) Cn [%mem.M 0, I32]]
auto mem_t = w.call<mem::M>(0);
auto argv_t = w.call<mem::Ptr0>(w.call<mem::Ptr0>(w.type_i32()));
auto main = w.mut_fun({mem_t, w.type_i32(), argv_t}, {mem_t, w.type_i32()})->set("main");
auto [mem, argc, argv] = main->var(2, 0)->projs<3>();
auto ret = main->var(2, 1);
main->app(false, ret, {mem, argc});
main->externalize();
// the `ll` plugin's emit phase writes `hello.ll` as part of `optimize`
sys::system("clang hello.ll -o hello -Wno-override-module");
std::println("exit code: {}", sys::system("./hello a b c"));
} catch (const std::exception& e) {
std::println(std::cerr, "{}", e.what());
return EXIT_FAILURE;
} catch (...) {
std::println(std::cerr, "error: unknown exception");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
Some "global" variables needed all over the place.
Definition driver.h:20
int main(int argc, char **argv)
Definition main.cpp:23
AST load_plugins(World &, View< Sym >)
Definition ast.cpp:243
The mem Plugin
Definition mem.h:11
int system(std::string)
Wraps std::system and makes the return value usable.
Definition sys.cpp:107
Definition ast.h:14
Span< const T, N > View
Definition span.h:102
void optimize(World &)
Runs _compile or _default_compile, if available (in this order).
Definition optimize.cpp:8

Driver is usually the first object you create. It owns a few global facilities such as Flags, the Log, and the current World. In this example, the log is configured to write debug output to std::cerr; see also Debugging Features.

Next, we load the core and ll plugins. A plugin has two halves — a .mim file declaring its annexes and a shared library providing their runtime behavior; see Plugins & Annexes for the overview and Plugins for the details. Calling mim::ast::load_plugins parses the .mim file and also loads the shared object, while the Driver keeps track of the resulting plugin state.

Now we can build actual code.

Def is the base class for all nodes/expressions in MimIR. Each Def is a node in a graph managed by the World. You can think of the World as a giant hash set that owns all Defs and provides factory methods to create them.

In this example, we construct the main function. In direct style, its type looks like this:

[%mem.M 0, I32, %mem.Ptr (I32, 0)] -> [%mem.M 0, I32]

In continuation-passing style (CPS), the same type looks like this:

Cn [%mem.M 0, I32, %mem.Ptr (I32, 0), Cn [%mem.M 0, I32]]

The type mem.M 0 tracks side effects. Since main introduces variables, we must create it as a mutable lambda; see Immutables vs. Mutables.

The body of main is simple: it invokes the return continuation ret with mem and argc:

ret (mem, argc)

It is also important to mark main as external. Otherwise, MimIR may remove it as dead code.

Finally, we optimize the program, emit an LLVM assembly file, compile it via clang, and execute the generated binary with ./hello a b c. We then print its exit code, which should be 4.

Immutables vs. Mutables

MimIR distinguishes between two kinds of Defs: immutables and mutables.

Immutable Mutable
must be const may be non-const
ops form a DAG ops may be cyclic
no recursion may be recursive
no variables may have variables; use mim::Def::var / mim::Def::has_var
build ops first, then the actual node build the actual node first, then set the ops
hash-consed each new instance is fresh
Def::rebuild Def::stub

Immutables

Immutables are usually constructed in one step with World factory methods. The usual pattern is: build all operands first, then create the immutable node with w.app, w.tuple, w.pi, w.sigma, and similar helpers.

For ordinary applications, mim::World::app is the direct building block:

auto f = w.annex<mem::alloc>();
auto app = w.app(w.app(f, {type, as}), mem);

Here, mim::World::annex yields the raw axiom node itself. That is useful when you want to partially apply a curried annex, store it, inspect it, or build the application tree step by step from C++.

If you want the full curried call in one go, prefer mim::World::call:

auto app = w.call<mem::alloc>(mem);
auto mem_t = w.call<mem::M>(0);
auto argv_t = w.call<mem::Ptr0>(w.call<mem::Ptr0>(w.type_i32()));

w.call<Id>(...) starts from w.annex<Id>() and completes the curried application for you, including implicit arguments. So the rule of thumb is:

  • use w.annex<Id>() when you need the annex itself or want manual partial application;
  • use w.call<Id>(...) when you want the fully applied operation directly.

Mutables

Mutables are built in three phases:

  1. Create the mutable node with a mut_* factory or a stub.
  2. Optionally, obtain its variable.
  3. Fill in the body via mim::Def::set:
auto main = w.mut_fun({mem_t, w.type_i32(), argv_t}, {mem_t, w.type_i32()})->set("main");
auto [mem, argc, argv, ret] = main->vars<4>();
main->app(false, ret, {mem, argc});
main->externalize();

Use mim::Def::externalize for roots that must survive cleanup and whole-world rewrites. Top-level entry points, generated wrapper functions, and replacement nodes for former externals all follow this pattern.

Binders

As a more intricate example, we build a polymorphic identity function using MimIR's C++ API.

λ {T: *} (x: T): T = x

This example illustrates how mutables and immutables interact. All binders must be created as mutables in order to access the variable they introduce. All other nodes can remain immutable.

#include <mim/world.h>
using namespace mim;
Lam* build_poly_id(World& w) {
auto pi = w.mut_pi(w.type<1>(), /*implicit*/ true)->set_dom(w.type());
auto pT = pi->var()->set("T");
pi->set_codom(w.pi(pT, pT)); // pi = {T: *} -> T -> T
auto lamT = w.mut_lam(pi); // outer lambda
auto lT = lamT->var()->set("T"); // note: this is lamT's T, NOT pi's T
auto lamx = w.mut_lam(w.pi(lT, lT)); // inner lambda
auto x = lamx->var()->set("x");
lamx->set(/*tt filter*/ true, x);
lamT->set(/*tt filter*/ true, lamx); // λ {T: *} (x: T): T = x
return lamT;
}
A function.
Definition lam.h:110
The World represents the whole program and manages creation of MimIR nodes (Defs).
Definition world.h:36

We first build the function type {T: *} → T → T (stored in pi). Since this type introduces the implicit variable T (stored in pT), the outer Pi must be created as a mutable. Before we can retrieve this variable, we must set the domain * (w.type()). This is also the reason why the Pi itself lives one universe level above, in w.type<1>(). The name "T" is purely for debugging and has no semantic meaning.

The codomain T → T is built as an immutable Pi that refers to pT, and is then assigned as the codomain of pi.

Next, we build the actual function. The outer lambda lamT has type pi and must be mutable, since it introduces the variable T (stored in lT) of type * (the domain of pi). Even though pi also introduces a T, lamT's variable is distinct: lT is not the same variable as pT.

We then build the inner lambda lamx, which must also be mutable because it introduces the variable x of type T.

Warning
The type of lamx is T → T, and must refer to lT (not pT), because lamx is nested inside lamT and thus depends on lamT’s variable.

Finally, we set the body of lamx to x (using a tt filter), and set the body of lamT to lamx, again using a tt filter.

Partial Evaluation

A tt filter tells MimIR to immediately β-reduce the corresponding application, giving the effect of partial evaluation.

auto res = w.call(lamT, w.lit_nat(42));

Here we use World::call to automatically infer the implicit argument (Nat) and apply the function to 42. Because both lambdas were constructed with a tt filter, MimIR reduces the application immediately. As a result, res does not point to a function or a residual call node, but directly to the reduced result, i.e. the literal 42.

Variables

In the example above we also see that variables are only created as needed. They are immutable, and their sole operand is the binder where they were introduced.

auto var = lam->var(); // create or retrieve the variable of lam
if (auto var = lam->has_var()) {
/* only true if lam's Var already exists */
}
if (auto [lam, var] = def->isa_binder<Lam>(); lam) {
/* only true if def is a mutable Lam whose Var already exists */
}
auto mut = var->mut(); // get the mutable binder where var was introduced

Which variables a Def actually refers to is answered by its free variables; see below.

Free Variables

Many analyses and rewrites need to know which variables a Def still refers to. For example, a Def can only be hoisted to an outer scope if it does not depend on the variables introduced further in.

Local vs. Global

Recall that the operand graph is a DAG of immutables that is only ever "broken" by mutables (see Immutables vs. Mutables). MimIR exploits this by splitting the analysis at the mutable boundary:

  • mim::Def::local_vars / mim::Def::local_muts only follow immutable deps. They stop as soon as they hit a mutable and record that mutable instead of descending into it. Because immutables are hash-consed, these sets are computed once at construction time, cached, and shared. By definition, var->local_vars() is {var} and mut->local_muts() is {mut}.
  • mim::Def::free_vars gives the actual set of free Vars. It extends local_vars() by transitively resolving the local_muts(). Since mutables may be (mutually) recursive, this is a fixed-point iteration whose result is cached inside each mutable.
Vars fvs = def->free_vars(); // all Vars that still occur free in def
bool open = def->is_open(); // fvs is non-empty
bool clsd = def->is_closed(); // fvs is empty
Sets< const Var >::Set Vars
Definition def.h:99
Note
Prefer local_vars() / local_muts() when a local answer suffices — they are free after construction — and only reach for free_vars() when you truly need the transitive closure.

Invalidation

Since mutables can be re-set, their cached free_vars() may become stale. Each mutable therefore tracks its users — the mutables that reference it — so that mutating a mutable transitively invalidates the caches of everything that (indirectly) depends on it. You do not trigger this yourself; it happens as part of set / unset.

Scope & Nesting

Free variables also underpin MimIR's scopeless nesting queries:

  • mim::Def::outermost_binder walks up free_vars() until the outermost enclosing binder is reached.
  • mim::Def::nests answers whether a mutable statically nests another Def. The relation is strict: f->nests(f) is false, and a def that only uses f's own Var sits at f's level and is likewise not nested.

Dep: A Cheap Approximation

When you only need a yes/no answer — does this subtree contain any Var, Hole, mutable, or Proxy?mim::Def::has_dep is far cheaper than materializing free_vars(). Like local_vars(), it is a per-node bitset (see mim::Dep) that only looks up to the next mutable:

if (def->has_dep(Dep::Var)) { /* def mentions some Var before the next mutable */ }
if (!def->has_dep()) { /* def is a fully closed, ground term */ }
@ Var
Depends on a Var.
Definition def.h:123

Matching IR

MimIR provides several ways to scrutinize Defs. Matching built-ins, i.e. subclasses of Def, works a little differently from matching axioms.

Downcasts for Built-ins

Methods beginning with

  • isa behave like dynamic_cast: they perform a runtime check and return nullptr if the cast fails;
  • as behave more like static_cast: in Debug builds they assert, via the corresponding isa, that the cast is valid.
  • expect behave like as, but - instead of merely asserting in Debug builds and being silently unchecked in Release - they always check via the corresponding isa and throw a formatted exception (via fe::throwf) when the cast fails. Reach for expect (over as) whenever the assumption is really a claim about the incoming IR that should surface as a proper error message rather than a Debug-only assertion or Release-mode undefined behavior - e.g. in backends that validate an already-lowered program.

General Downcast

Def::isa / Def::as perform a downcast that matches both mutables and immutables:

void foo(const Def* def) {
if (auto sigma = def->isa<Sigma>()) {
// sigma has type "const Sigma*" and may be mutable or immutable
}
// sigma has type "const Sigma*" and may be mutable or immutable
// asserts if def is not a Sigma
auto sigma = def->as<Sigma>();
// sigma has type "const Sigma*" and may be mutable or immutable
// throws an exception (via fe::throwf) like "expected a struct type, but got '<def>'" if def is not a Sigma;
// the argument is a description of what was expected - a plain string or a format string plus arguments
auto s1 = def->expect<Sigma>("a struct type");
auto s2 = def->expect<Sigma>("the operand of {}", parent);
// the mutable counterpart, mirroring Def::as_mut; yields "Lam*"
auto lam = def->expect_mut<Lam>("a mutable continuation");
}
Base class for all Defs.
Definition def.h:261
T * expect_mut(std::format_string< Args... > fmt, Args &&... args) const
Like Def::as_mut but - instead of merely asserting in Debug builds - throws via fe::throwf when the c...
Definition def.h:548
A dependent tuple type.
Definition tuple.h:22

Downcast to Immutables

mim::Def::isa_imm / mim::Def::as_imm only match immutables:

void foo(const Def* def) {
if (auto imm = def->isa_imm()) {
// imm has type "const Def*" and is immutable
}
if (auto sigma = def->isa_imm<Sigma>()) {
// sigma has type "const Sigma*" and is an immutable Sigma
}
// sigma has type "const Sigma*" and must be an immutable Sigma
// otherwise, this asserts
auto sigma = def->as_imm<Sigma>();
}
const T * as_imm() const
Definition def.h:522
const T * isa_imm() const
Definition def.h:521

Downcast to Mutables

mim::Def::isa_mut / mim::Def::as_mut only match mutables. They also remove the const qualifier, which gives you access to the non-const methods that only make sense for mutables:

void foo(const Def* def) {
if (auto mut = def->isa_mut()) {
// mut has type "Def*" - note that "const" has been removed
// This gives you access to the non-const methods:
auto var = mut->var();
auto stub = mut->stub(type);
// ...
}
if (auto lam = def->isa_mut<Lam>()) {
// lam has type "Lam*"
}
// lam has type "Lam*" and must be a mutable Lam
// otherwise, this asserts
auto lam = def->as_mut<Lam>();
}
T * as_mut() const
Asserts that this is a mutable, casts constness away and performs a static_cast to T.
Definition def.h:536
T * isa_mut() const
If this is mutable, it will cast constness away and perform a dynamic_cast to T.
Definition def.h:527

If the scrutinee is already a Def*, then Def::isa / Def::as behave the same as mim::Def::isa_mut / mim::Def::as_mut, because the missing const already implies mutability:

void foo(Def* def) {
if (auto sigma = def->isa<Sigma>()) {
// sigma has type "Sigma*" and is mutable
}
if (auto sigma = def->isa_mut<Sigma>()) {
// sigma has type "Sigma*" and is mutable
}
// sigma has type "Sigma*" and must be mutable
// otherwise, this asserts
auto sigma = def->as<Sigma>();
}

Matching Literals

Often, you want to match a literal and extract its value. Use Lit::isa / Lit::as:

void foo(const Def* def) {
if (auto lit = Lit::isa(def)) {
// lit has type "std::optional<u64>"
// it is your responsibility to interpret the value correctly
}
if (auto lit = Lit::isa<f32>(def)) {
// lit has type "std::optional<f32>"
// it is your responsibility to interpret the value correctly
}
// asserts if def is not a Lit
auto lu64 = Lit::as(def);
auto lf32 = Lit::as<f32>(def);
// throws an exception (via fe::throwf) like "expected an address space, but got '<def>'" if def is not a Lit
auto a = Lit::expect(def, "an address space");
auto f = Lit::expect<f32>(def, "a floating-point constant");
}
static std::optional< T > isa(const Def *def)
Definition def.h:878
static T as(const Def *def)
Definition def.h:884
static T expect(const Def *def, std::format_string< Args... > fmt, Args &&... args)
Like Lit::as but throws a formatted mim::error instead of merely asserting in Debug; see Def::expect.
Definition def.h:889

Summary

The following table summarizes the most important casts:

A method beginning with expect behaves like the as in the same row, but throws a formatted exception (via fe::throwf) instead of asserting; it takes a description (a plain string or a format string plus arguments) of what was expected.

dynamic_cast
static_cast
throwing
Returns If def is a ...
def->isa<Lam>()
def->as<Lam>()
def->expect<Lam>(fmt, ...)
const Lam* Lam
def->isa_imm<Lam>()
def->as_imm<Lam>()
const Lam* immutable Lam
def->isa_mut<Lam>()
def->as_mut<Lam>()
def->expect_mut<Lam>(fmt, ...)
Lam* mutable Lam
Lit::isa(def)
Lit::as(def)
Lit::expect(def, fmt, ...)
std::optional<nat_t>
nat_t
Lit
Lit::isa<f32>(def)
Lit::as<f32>(def)
Lit::expect<f32>(def, fmt, ...)
std::optional<f32>
f32
Lit

Further Casts

There are also several specialized checks, usually provided as static methods. They return either a pointer to the matched entity or nullptr.

Here are a few examples:

void foo(const Def* def) {
if (auto size = Idx::isa(def)) {
// def = Idx size
}
if (auto lam = Lam::isa_mut_cn(def)) {
// def is a mutable Lam of type Cn T
}
if (auto pi = Pi::isa_basicblock(def)) {
// def is a Pi whose codomain is bottom and which is not returning
}
// yields the bit width of an Idx type, or throws a formatted exception (via fe::throwf) if it is not statically known
// (the throwing counterpart of the std::optional-returning Idx::size2bitwidth)
auto w = Idx::expect_bitwidth(def, "an index type of known width");
}
static const Def * isa(const Def *def)
Checks if def is a Idx s and returns s or nullptr otherwise.
Definition def.cpp:658
static nat_t expect_bitwidth(const Def *type, std::format_string< Args... > fmt, Args &&... args)
Yields the bit width of the Idx type or throws a formatted mim::error - instead of yielding std::null...
Definition def.h:959
static Lam * isa_mut_cn(const Def *d)
Only for mutables.
Definition lam.h:144
static const Pi * isa_basicblock(const Def *d)
Is this a continuation (Pi::isa_cn) that is not Pi::isa_returning?
Definition lam.h:51

Matching Axioms

You can match axioms via

  • mim::Axm::isa, which behaves like a checked dynamic_cast and returns a wrapped nullptr-like value on failure,
  • mim::Axm::as, which behaves like a checked static_cast and asserts in Debug builds if the match fails, or
  • mim::Axm::expect, which - like the other expect helpers - throws a formatted exception (via fe::throwf) instead of asserting: Axm::expect<mem::Ptr>(def, "a mem.Ptr").

The result is a mim::Axm::isa<Id, D>, which wraps a const D*. Here, Id is the enum corresponding to the matched axiom tag, and D is usually an App, because most axioms inhabit a function type. In other cases, it may wrap a plain Def or some other subclass.

By default, MimIR assumes that an axiom becomes "active" when its final curried argument is applied. For example, matching %mem.load only succeeds on the final App of the curried call

%mem.load (T, as) (mem, ptr)

whereas

%mem.load (T, as)

does not match.

In this example, the wrapped App refers to the final application, so:

Use mim::App::decurry if you want direct access to the preceding application. See the examples below.

If you design an axiom that returns a function, you can fine-tune the trigger point of mim::Axm::isa / mim::Axm::as.

Without Subtags

To match an axiom without subtags, such as %mem.load, use:

void foo(const Def* def) {
if (auto load = Axm::isa<mem::load>(def)) {
auto [mem, ptr] = load->args<2>();
auto [pointee, addr_space] = load->decurry()->args<2>();
}
// def must match mem::load - otherwise, this asserts
auto load = Axm::as<mem::load>(def);
// def must match mem::load - otherwise, this throws a formatted exception (via fe::throwf)
auto ld = Axm::expect<mem::load>(def, "a %mem.load");
}
static auto isa(const Def *def)
Definition axm.h:107
static auto as(const Def *def)
Definition axm.h:130
static auto expect(const Def *def, std::format_string< Args... > fmt, Args &&... args)
Like Axm::as but - instead of merely asserting in Debug builds - throws a formatted mim::error when d...
Definition axm.h:138

With Subtags

To match an axiom with subtags, such as %core.wrap, use:

void foo(const Def* def) {
if (auto wrap = Axm::isa<core::wrap>(def)) {
auto [a, b] = wrap->args<2>();
auto mode = wrap->decurry()->arg();
switch (wrap.id()) {
case core::wrap::add: // ...
case core::wrap::sub: // ...
case core::wrap::mul: // ...
case core::wrap::shl: // ...
}
}
if (auto add = Axm::isa(core::wrap::add, def)) {
auto [a, b] = add->args<2>();
auto mode = add->decurry()->arg();
}
// def must match core::wrap - otherwise, this asserts
// def must match core::wrap::add - otherwise, this asserts
auto add = Axm::as(core::wrap::add, def);
}

Summary

The following table summarizes the most important axiom matches:

dynamic_cast
static_cast
Returns If def is a ...
isa<mem::load>(def)
as<mem::load>(def)
mim::Axm::isa specialized for mem::load and App final curried %mem.load application
isa<core::wrap>(def)
as<core::wrap>(def)
mim::Axm::isa specialized for core::wrap and App final curried %core.wrap application
isa(core::wrap::add, def)
as(core::wrap::add, def)
mim::Axm::isa specialized for core::wrap and App final curried %core.wrap.add application

Working with Indices

In MimIR, there are essentially three ways to talk about the number of elements of something.

Arity

The arity is the number of elements available to extract / insert. This number may itself be dynamic, for example in ‹n; 0›.

Projs

mim::Def::num_projs equals mim::Def::arity if the arity is a mim::Lit. Otherwise, it yields 1.

This concept mainly exists in the C++ API to give you the illusion of n-ary structure, e.g.

for (auto dom : pi->doms()) { /*...*/ }
for (auto var : lam->vars()) { /*...*/ }

Internally, however, all functions still have exactly one domain and one codomain.

Thresholded Variants

There are also thresholded variants prefixed with t, which take mim::Flags::scalarize_threshold (--scalarize-threshold) into account.

mim::Def::num_tprojs behaves like mim::Def::num_projs, but returns 1 if the arity exceeds the threshold. Similarly, mim::Def::tproj, mim::Def::tprojs, mim::Lam::tvars, and related methods follow the same rule.

See also:

Shape

TODO

Summary

Expression Class arity num_projs num_tprojs
(0, 1, 2) Tuple 3 3 3
‹3; 0› Pack 3 3 3
‹n; 0› Pack n 1 1
[Nat, Bool, Nat] Sigma 3 3 3
«3; Nat» Arr 3 3 3
«n; Nat» Arr n 1 1
x: [Nat, Bool] Var 2 2 2
‹32; 0› Pack 32 32 1

The last line assumes mim::Flags::scalarize_threshold = 32.

Iterating over the Program

There are several ways to iterate over a MimIR program. Which one is best depends on what you want to do and how much structure you need during the traversal.

The simplest approach is to start from World::annexes and World::externals and recursively visit Def::deps. Oftentimes, you can use World::roots if you don't need to distinguish between annexes and externals:

void visit(DefSet& done, const Def* def) {
if (!done.emplace(def).second) return;
do_sth(def);
for (auto op : def->deps())
visit(done, op);
}
void visit() {
DefSet done;
for (auto def : world.roots())
visit(done, def);
}
Defs deps() const noexcept
Definition def.cpp:514
GIDSet< const Def * > DefSet
Definition def.h:76

In practice, though, you will usually want to use the phase infrastructure instead.