A map of the territory
A compiler is a translator. It accepts a program written for humans and produces a program a computer can execute.
That simple sentence hides several translations. A compiler rarely leaps straight from something likea + bto processor instructions. It first turns the program into internal forms that are easier to inspect and change.
What this article assumes
You can read basic code and know what a function, variable, loop, and type are. You do not need to know how parsers, assembly, LLVM, or compiler theory work.
The four broad stages
Compiler engineers often group the work into afront end, a middle end, and a back end. These are roles, not necessarily separate programs.
The compiler's internal form is called an intermediate representation, usually shortened to IR. “Intermediate” means it sits between source code and the final machine code. “Representation” means it is another way of expressing the program.
A front end also commonly creates an abstract syntax tree, or AST. This is the parsed shape of the source. The word “abstract” means it keeps meaningful structure—such as “addition” and “function call”—while dropping punctuation that no longer matters.
2 + 3 * 4 Add
/ \
2 Multiply
/ \
3 4The tree records that multiplication happens before addition. An IR can then make the computation explicit, give every intermediate result a name, and provide a regular structure for transformations.
A compiler is not one heroic translation. It is a sequence of small, checkable changes in how the same program is represented.
Why MLIR exists
MLIRmeans Multi-Level Intermediate Representation . The name is its thesis: a compiler should be able to preserve several useful levels of meaning instead of forcing everything into one low-level form too soon.
LLVMis a mature compiler project used by Rust, Clang, Swift, and many other tools. Its name was historically expanded as “Low Level Virtual Machine,” but today LLVM is simply the project's name. Its best-known IR is deliberately close to the needs of conventional machines. That is excellent near the end of compilation, but less natural for early concepts such as a structured loop, a tensor operation, an ownership rule, or an effect handler.
MLIR is both an extensible IR and infrastructure for defining, checking, rewriting, and lowering IR. It does not replace LLVM. A common pipeline uses MLIR for high- and middle-level transformations, then lowers into LLVM's world for machine-code generation.
Dialects: several vocabularies in one IR
MLIR stays extensible through dialects. A dialect is a named vocabulary of operations, types, and compile-time data. The prefix before the dot tells you which vocabulary an operation belongs to:
| Example | Read it as | Purpose |
|---|---|---|
arith.addi | the addi operation from the arithmetic dialect | integer addition |
func.call | the call operation from the function dialect | call a function |
scf.if | the if operation from the structured-control-flow dialect | preserve an if/else |
cf.br | the br operation from the control-flow dialect | jump to another block |
llvm.load | the load operation from the LLVM dialect | low-level memory access |
A module may contain several dialects at the same time. That is not an untidy halfway state; it is an intentional feature. One part of a program can be lowered while another still retains a higher-level form.
The mental model
MLIR is not a single instruction set. It is a common grammar for hosting many related instruction sets—and a framework for moving between them.
Meet our tiny language
We will learn more from one tiny program that evolves than from dozens of disconnected fragments.
Call the language Frog. Version one has only 32-bit integers, arithmetic, comparisons, immutable and mutable local variables, functions, if,while, and return. It deliberately has no structs, generics, methods, arrays, or ownership system yet.
fn factorial(n: i32) -> i32 {
let mut result = 1;
let mut i = 1;
while i <= n {
result = result * i;
i = i + 1;
}
return result;
}A compiler for Frog can follow this route:
Lex the text.Group characters into tokens such as fn,factorial, (, andi32.
Parse the tokens.Build an abstract syntax tree that records functions, statements, expressions, and their nesting.
Analyse meaning.Resolve each name to its definition and confirm that operations use compatible types.
Generate MLIR.Express the checked program with operations from MLIR dialects.
Transform and lower it. Simplify the program and progressively replace high-level operations with lower-level ones.
Generate executable code. Hand the low-level result to LLVM so it can target a real processor.
The lexer and parser answer “what did the programmer write?” Semantic analysis answers “does it make sense?” The IR answers “how can the compiler represent and transform that meaning?”
In MLIR, everything is an operation
Addition is an operation. Returning from a function is an operation. A function definition is an operation. Even a module containing functions is an operation.
Consider one line of MLIR:
%sum = arith.addi %a, %b : i32Read it as: “take the values named %aand%b, add them as 32-bit integers, and name the result %sum.” The names beginning with%are local value names, not mutable source-language variables.
arith.addihas convenient custom syntax. Its fully generic form makes the shared structure more obvious:
%sum = "arith.addi"(%a, %b)
: (i32, i32) -> i32This uniform model is the key to MLIR's extensibility. The framework knows how to store and traverse operations without having a hard-coded list of every operation that could ever exist. A dialect supplies each operation's specific meaning and rules.
Operations may produce several results
There is no “one instruction, one result” restriction. A division operation can naturally return both a quotient and a remainder:
%quotient, %remainder =
frog.divrem %a, %b : i32 -> (i32, i32)Operations contain regions; regions contain blocks
MLIR is recursive. An operation can own a nested piece of IR, so high-level structure can remain visible instead of being flattened immediately.
The three structural nouns are:
- An operation represents some action or construct.
- A region is a body of IR owned by an operation.
- A block is an ordered list of operations, optionally with input values called block arguments.
module {
func.func @add(%a: i32, %b: i32) -> i32 {
%result = arith.addi %a, %b : i32
func.return %result : i32
}
}A region is more general than a lexical scope. The operation that owns it defines what the region means. For a function, it is the function body. For an if, regions are alternatives. For a loop, a region is repeatedly executed. A future Frog dialect could use regions to preserveunsafe, transaction, or effect-handling bodies.
Why this matters
A compiler can still see “this is an if” or “this is a loop.” That information is valuable to analyses and optimisations, so MLIR lets you keep it until a lower-level stage truly needs branches and jumps.
Values and static single assignment
MLIR commonly represents values usingstatic single assignment, abbreviatedSSA: each value is defined exactly once.
“Static” means the rule is about the program text the compiler sees, not how often code runs. “Single assignment” means one SSA name has one definition. It does not mean your source language cannot have mutable variables.
This source:
let mut x = 10;
x = x + 1;
x = x * 2;
return x;can become:
%x0 = arith.constant 10 : i32
%one = arith.constant 1 : i32
%x1 = arith.addi %x0, %one : i32
%two = arith.constant 2 : i32
%x2 = arith.muli %x1, %two : i32
func.return %x2 : i32The frontend updates its own mapping from the source namexto the latest SSA value. The three MLIR values%x0, %x1, and %x2never change.
%x2depends on%x1, which depends on %x0.An MLIR value comes from one of two places: it is either the result of an operation or an argument supplied to a block. The second case solves an important problem: how values meet after control flow splits.
Block arguments: values arriving at a destination
Suppose one branch calculates 10 and another calculates 20. At the point where they rejoin, the next block needs “whichever value the executed branch produced.” LLVM traditionally represents this with a special phi node(phi is pronounced “fye”). MLIR usually expresses it as a block argument:
cf.cond_br %condition, ^true, ^false
^true:
%a = arith.constant 10 : i32
cf.br ^continue(%a : i32)
^false:
%b = arith.constant 20 : i32
cf.br ^continue(%b : i32)
^continue(%x: i32):
// %x is 10 or 20, depending on the path taken.Do not allocate memory by reflex
A beginner compiler often turns every mutable local into a stack slot plus loads and stores. That works, but hides data flow that the frontend already knows. Keep ordinary locals in SSA when possible; use memory when a value genuinely needs an address or storage identity.
Structured control flow: ifs and loops
The scf dialect—short forstructured control flow—represents constructs such as if, for, andwhilewithout immediately flattening them into jumps.
An if that returns a value
Frog source:
fn max(a: i32, b: i32) -> i32 {
if a > b { a } else { b }
}MLIR:
func.func @max(%a: i32, %b: i32) -> i32 {
%condition = arith.cmpi sgt, %a, %b : i32
%result = scf.if %condition -> (i32) {
scf.yield %a : i32
} else {
scf.yield %b : i32
}
func.return %result : i32
}arith.cmpicompares integers. The sgt attribute means “signed greater than.” The scf.ifoperation owns two regions. Each region ends withscf.yield, which sends a value back to the containing if. The operation itself produces %result.
scf.if.A loop carries values from one iteration to the next
In source code, our factorial loop repeatedly changesresultand i. In SSA, each iteration receives the current values and yields the next values. Think of the loop body as a function repeatedly called with its own previous output.
Eventually, a compiler can lower structured control flow to the cf dialect, where CFG meanscontrol-flow graph: blocks are nodes and branches are edges. Later still, those branches can become LLVM operations. The key is timing: preserve the structured form while it remains useful.
Types, attributes, and symbols
Three things that look like “extra information” play different roles. Learning the distinction makes MLIR much easier to read.
| Concept | Working definition | Example |
|---|---|---|
| Value | Data that flows between operations while the program runs | %sum, %condition |
| Type | The kind of data a value contains and the rules that apply to it | i32, f64,!frog.string |
| Attribute | Compile-time-known configuration attached to IR | the integer 42; the comparison kindsgt |
| Symbol | A stable name for a declaration, looked up through a symbol table | @factorial |
Types are extensible
Built-in MLIR types include integers such as i32, floating-point numbers such asf64, and parameterised types such asvector<4xi32>,tensor<10x20xf32>, andmemref<100xi32>. Dialects can define their own types:
!llvm.ptr
!frog.string
!frog.ref<i32>
!frog.struct<"Point">
!frog.owned<!frog.buffer>The leading ! marks a dialect-defined type. An open type system means Frog does not have to erase “string,” “borrow,” or “owned buffer” into raw pointers before the analyses that understand those ideas have run.
Attributes are facts embedded in the IR
%answer = arith.constant 42 : i32
%is_larger = arith.cmpi sgt, %a, %b : i32%answeris a runtime SSA value of type i32. The literal42is stored as an attribute on the constant operation. In the second line, sgtis an attribute selecting the comparison, while%aand%bare operands.
%valueand @symbol are different namespaces
A name beginning with % identifies a local SSA value. A name beginning with@identifies a symbol such as a function or global declaration:
func.func @square(%x: i32) -> i32 {
%result = arith.muli %x, %x : i32
func.return %result : i32
}
%four = func.call @square(%two) : (i32) -> i32Symbols allow one part of the IR to refer to a declaration without turning that declaration into a local data-flow value.
Patterns, passes, and pipelines
A compiler mostly recognises shapes and replaces them with better or lower-level shapes.
A rewrite pattern is a local rule. It may replace:
frog.increment %xwith:
%one = arith.constant 1 : i32
%result = arith.addi %x, %one : i32A pass is a named compiler phase that performs an analysis or transformation over some portion of the IR. A pass pipeline is an ordered recipe of passes.
Lowering versus optimisation
| Purpose | Example | |
|---|---|---|
| Canonicalisation | Choose a simpler standard form at the same general abstraction level | x + 0 → x |
| Optimisation | Improve some property while preserving behaviour | remove a repeated calculation |
| Lowering | Replace a concept with more primitive concepts | frog.increment → arith.addi |
Mechanically, all three can be graph rewrites. Their intent differs.
Verification makes invalid states loud
An operation definition can state thatfrog.addrequires two integer operands of the same type and produces one result of that type. A verifier rejectsi32 + f64before later stages have to guess what it means.
MLIR operations can also advertise reusable properties throughtraitsand reusable behaviours throughinterfaces. A pass can ask “does this operation behave like a loop?” or “what memory effects does it have?” rather than containing a growing list of operation names.
Four useful nouns
Operation: an IR construct.Pattern: a local rewrite rule.Pass: a compiler phase.Pipeline: the ordered sequence of phases.
Build the first compiler in Rust
Melior is a Rust interface to MLIR's C API. It lets a Rust program construct MLIR, run passes, and use MLIR's execution infrastructure without writing the compiler frontend in C++.
As of 12 September 2026, the current docs.rs release page isMelior 0.27.4. Because Melior tracks specific LLVM/MLIR versions, treat its crate documentation and build instructions as the source of truth for compatible toolchains rather than copying an old version number from a tutorial.
Begin with existing dialects
Do not make the first milestone “design a complete custom dialect.” First map Frog directly into the existingfunc, arith, andscfdialects. This keeps the number of new ideas under control.
fn main() -> i32 {
return 10 + 20 * 3;
}becomes:
module {
func.func @main() -> i32 {
%c10 = arith.constant 10 : i32
%c20 = arith.constant 20 : i32
%c3 = arith.constant 3 : i32
%0 = arith.muli %c20, %c3 : i32
%1 = arith.addi %c10, %0 : i32
func.return %1 : i32
}
}Your expression compiler is mainly a recursive walk plus an environment—a map from source variable names to MLIR values:
fn compile_expr(expr: &Expr, env: &Environment) -> Value {
match expr {
Expr::Integer(n) => emit_constant(*n),
Expr::Variable(name) => env[name],
Expr::Add(left, right) => {
let left = compile_expr(left, env);
let right = compile_expr(right, env);
emit_addi(left, right)
}
Expr::Multiply(left, right) => {
let left = compile_expr(left, env);
let right = compile_expr(right, env);
emit_muli(left, right)
}
}
}A sequence that keeps every step runnable
Write MLIR by hand.Use func and arith; run it through mlir-optso syntax and verification become tangible.
Generate the same module from Melior. Learn contexts, locations, types, blocks, regions, and operation builders.
Parse integer expressions. Support literals, parentheses, precedence, and+ − × ÷.
Add names and immutable let.Store each binding's MLIR value in an environment; do not emit a letoperation.
Add functions and calls. Learn function regions, entry-block arguments, symbols, and returns.
Add expression-valued if.Learn scf.if, regions, andscf.yield.
Add while and mutation. Represent changing source variables with loop-carried SSA values.
Run standard passes. Try canonicalisation and CSE—common subexpression elimination, which removes repeated equivalent calculations.
Lower and execute.Convert structured control flow and arithmetic to the LLVM dialect, translate to LLVM IR, and run native code.
Now create frog.*. Re-express selected source semantics in a custom dialect and lower it to the working pipeline.
Keep a golden file for the MLIR after each milestone. It gives you something readable to inspect, diff, and test independently of runtime output.
When your own dialect earns its keep
A custom dialect is useful when existing operations would erase a distinction your language still needs.
Suppose Frog guarantees checked integer arithmetic. Emittingarith.addiimmediately may no longer communicate “trap if this addition overflows.” A high-level operation keeps that contract explicit:
%result = frog.checked_add %a, %b : !frog.intLater, after relevant analysis and optimisation, it can lower to an overflow-detecting LLVM operation plus a conditional trap. The same principle applies to future ownership or effect features:
frog.move %value
%ref = frog.borrow %value
frog.end_borrow %refSemantic type versus machine representation
A high-level string type may be written as!frog.string. Its eventual machine representation might be three fields: a pointer, a length, and a capacity. Those are not the same idea.
MLIR's dialect-conversion framework makes the transition disciplined. You declare which operations are legal in the target and which are illegal, then provide rewrite patterns. If frog.checked_add remains after a conversion that promised to eliminate Frog operations, the conversion fails.
Prototype before formalising
MLIR can permit unregistered dialects, and Melior exposes that facility. This makes it possible to experiment with generic operations such as "frog.add" before defining parsers, printers, verifiers, traits, and custom types.
For a serious dialect, operation definitions are commonly described using ODS, theOperation Definition Specification, built on LLVM's TableGen language. MLIR also includes IRDL, the Intermediate Representation Definition Language , which describes dialect constructs using MLIR itself. Both are worth learning after the first end-to-end compiler works, not before.
An architecture for a larger language
A Rust- or Mojo-scale language will probably need more than one language-specific level. The important design question is which facts each level must preserve.
A useful test for every boundary is:what can the compiler still prove here, and which facts become unrecoverable after this lowering?Specialise generics while generic definitions and type arguments are available. Check ownership while moves and borrows remain explicit. Resolve effects while effect structure is still visible. Lower each concept only when no later pass needs it.
Represent semantics, not syntax—and erase semantics only after the compiler has extracted their value.
That is the deeper MLIR lesson. The framework is useful, but the habit of preserving meaning until the right moment is the part worth carrying into any compiler design.
Plain-English glossary
- AST
- Abstract syntax tree. A tree representing the meaningful grammatical structure of source code.
- Attribute
- Compile-time-known data attached to an operation, such as a constant value or comparison kind.
- Block
- An ordered list of operations, with optional arguments representing values arriving at the block.
- CFG
- Control-flow graph.A graph whose nodes are blocks and whose edges show possible jumps.
- CSE
- Common subexpression elimination. Reusing one calculation when an equivalent calculation appears more than once.
- Dialect
- A named MLIR vocabulary that may define operations, types, and attributes.
- Frontend
- The compiler stages that read source code, build syntax, resolve names, and check meaning.
- IR
- Intermediate representation. A compiler's internal representation of a program.
- IRDL
- Intermediate Representation Definition Language.An MLIR dialect for describing dialect definitions.
- LLVM
- A compiler project providing a low-level IR, optimisation machinery, and code generation for many processors.
- Lowering
- Replacing a higher-level construct with one or more lower-level constructs while preserving behaviour.
- MLIR
- Multi-Level Intermediate Representation.An extensible compiler IR and transformation framework.
- ODS
- Operation Definition Specification. The declarative system commonly used to define MLIR operations.
- Operation
- MLIR's universal IR node: it consumes operands, may produce results, and may own regions.
- Pass
- A named compiler phase that analyses or transforms some part of the IR.
- Pattern
- A local transformation rule that recognises an IR shape and rewrites it.
- Region
- A nested body of blocks owned by an operation; the owning operation defines its meaning.
- SCF
- Structured control flow. The MLIR dialect for structured constructs such as ifs and loops.
- SSA
- Static single assignment. A representation in which every value is defined exactly once.
- Symbol
- A stable, symbol-table-resolved name for a declaration such as a function or global.
- Type
- The classification and rules of a value, such as
i32or a dialect-specific string type. - Verifier
- Code that checks whether an operation or larger piece of IR obeys its declared invariants.
Primary references
- MLIR Language Reference— the operation, region, block, value, type, symbol, and dialect model.
- Understanding the IR Structure— an official structural walkthrough.
- Structured Control Flow dialect— precise semantics for
scf.if,scf.for, andscf.while. - Dialect Conversion— conversion targets, legality, rewrite patterns, and type conversion.
- Pass InfrastructureandOperation Canonicalization.
- Operation Definition SpecificationandIRDL dialect.
- Melior on docs.rs— current Rust API release and build metadata.
- The official MLIR Toy tutorial— a deeper C++-based companion.
The next concrete step
Build onlyfn main() -> i32 { return 10 + 20 * 3; }. Hand-write its MLIR, generate the same IR from Rust, then make your parser produce it. That small vertical slice turns every term in this article into something you can touch.