Real Worldimplementation

The Rust Pipeline

Rust runs a program through more distinct representations than any other mainstream compiler, and each one exists to make a specific check possible: traits need types, borrow checking needs a control-flow graph, and monomorphization needs both. LLVM only sees the last of it.

The question

Why does rustc have so many intermediate representations, and why is my Rust build slow?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Seven, in sequence, each narrower and more explicit than the last. A token stream; an AST that still contains macro invocations; HIR, a desugared tree where for loops, ? and method call syntax have become explicit forms and every name is resolved; THIR, the same shape with a type on every expression once trait resolution has chosen the implementations; MIR, a control-flow graph of basic blocks over places and locals, with every drop, every bounds check and every panic edge written out; a monomorphized collection of MIR instances, one per concrete substitution actually reachable; and finally LLVM IR. The question each exists to answer is different, and that is the argument for having them: traits cannot be resolved on an AST, and borrows cannot be checked on a tree.

What this phase may assume or do

Each stage is entitled to assume the previous one succeeded, and the ordering is what makes the assumptions sound: macro expansion may assume the token stream parsed, type checking may assume names resolved, borrow checking may assume the program type-checked and that MIR construction inserted every implicit drop and every panic edge, and code generation may assume the borrow checker proved the aliasing invariants it is about to tell LLVM to rely on. That last assumption is the load-bearing one: rustc emits noalias on &mut parameters, which licenses optimizations that would be wrong for a C pointer, and it is legal only because a proof ran several representations earlier.

Key points

  • Each of rustc's representations exists to make one specific check possible; the phase list is a consequence of the checks, not of taste.
  • Borrow checking moved onto MIR because the property it proves is a dataflow fact about paths, which cannot be stated on a tree.
  • Non-lexical lifetimes were enabled by that move: the representation changed first and the language rule followed.
  • Macro expansion happens before types exist, so macros see syntax only; proc macros are compiled programs the compiler executes during your build.
  • Monomorphization happens on MIR, so a dependency ships MIR and its generic code is compiled in the crate that uses it.
  • LLVM sees the program only after every safety guarantee has been established, and contributes none of them.
  • Build time splits into frontend, monomorphization, LLVM and linking, and each has a different fix and a different tool for finding it.

Seven representations, each buying one check

implementationThis is rustc as of the 2024–2025 stable releases and every part of it is internal. MIR did not exist before 2016; THIR replaced an earlier IR called HAIR; non-lexical lifetimes shipped with the 2018 edition; Polonius, a reformulation of the borrow checker as a datalog problem, has been in development for years and would change the borrow-check stage without changing the language. There is no specification that requires this list, no other implementation obliged to follow it, and the -Z flags that dump these forms are nightly-only and change without notice.

The usual reaction to rustc's phase list is that it looks excessive. It is not decoration: each lowering exists because a specific analysis was impossible in the form before it. Macro expansion cannot wait for types, because a macro can generate the item that defines the type. Trait resolution cannot happen on the AST, because it needs to know what the expressions *are*. Borrow checking cannot happen on a tree, because the property it proves — that a mutable borrow does not overlap another live borrow along any path — is a statement about control flow, not about nesting.

MIR is the one that changed the language. Before it existed, lifetimes were lexical: a borrow lasted to the end of its enclosing block, whether or not it was used again. That rule is checkable on a tree and it rejects an enormous amount of obviously correct code. Non-lexical lifetimes replaced it with a dataflow analysis over a control-flow graph — a borrow is live where it may still be used — and that analysis is only expressible once the program *is* a graph. The representation came first, and the language rule followed it.

The other thing MIR bought is a place to do work that LLVM should not be asked to do. Drop elaboration, which decides where destructors run and inserts the flags that track conditional drops, is a Rust semantic question with no LLVM equivalent. Const evaluation runs on MIR too. And inlining a little at MIR level, before monomorphization multiplies everything, is much cheaper than inlining the same call in every instantiation afterwards.

rustc, from a .rs file to machine codeimplementation
  1. Source textyou write it
    A .rs file, UTF-8, in a crate — the compilation unit is the whole crate, not the file.
  2. Tokens and ASTbuild time
    A syntax tree that still contains unexpanded macro invocations.
    Structure. Not much else can happen yet, because a macro may generate items that later phases need.
  3. Macro expansion + name resolutionbuild time
    An AST with every macro replaced by what it produced, and every path resolved.
    The complete set of items in the crate, which was not knowable before expansion, and a binding for every name — see [[name-resolution]].
    The invocation site as written. Diagnostics inside a macro need the span machinery to point back at your code rather than at the expansion.
  4. HIRbuild time
    A desugared tree: for, ?, if let, method call sugar and implicit references all made explicit.
    A small, uniform set of forms for the type checker to reason about instead of the whole surface syntax — see [[desugaring]].
    Surface syntax. A for loop and the equivalent loop over an iterator are now indistinguishable.
  5. Type check + trait resolutionbuild time
    HIR with inference complete and every trait obligation discharged to a concrete impl.
    Which impl each method call means, which is the expensive part and the part generic code makes expensive — see [[ad-hoc-polymorphism]].
  6. THIRbuild time
    A typed tree, still tree-shaped, used as the bridge to MIR construction and for exhaustiveness checking.
    A form with types attached but without the trait machinery, simple enough to lower from mechanically.
  7. MIRbuild time
    A control-flow graph of basic blocks over locals and places, with drops, bounds checks and unwind edges explicit.
    Control flow as a graph, which is what makes a dataflow borrow check possible at all — see [[control-flow-graph]].
    Expression nesting, and the last of the surface language.
  8. Borrow checkbuild time
    The same MIR, now proven: no aliasing &mut, no use after move, no reference outliving its referent.
    The aliasing guarantee that later becomes noalias metadata for LLVM. This is where the language's central promise is actually established.
  9. Monomorphizationbuild time
    A set of concrete MIR instances — one per generic function per substitution actually reachable.
    Concrete types everywhere, so every call is direct and every layout is known — see [[monomorphization]].
    Genericity. From here the amount of code is proportional to the substitutions, not to what you wrote.
  10. LLVM IR and codegenbuild time
    LLVM IR partitioned into codegen units, then object files.
    Everything LLVM does: [[inlining]], vectorization, instruction selection, register allocation.
    Rust. From this point the pipeline is [[llvm-architecture]] and knows nothing about ownership.

Read it asRead where each guarantee is established. Type safety at the type-check stage, exhaustiveness at THIR, memory safety at the borrow check on MIR — all before LLVM has seen a single instruction. LLVM contributes speed and contributes nothing to safety, which is why an unsafe block that lies is not caught anywhere downstream.

Borrow checking is a dataflow analysis, which is why it moved

implementationNon-lexical lifetimes are a property of the current implementation's analysis, not of a written specification: what rustc accepts has grown as the analysis improved, and Polonius would accept a further set of programs (notably some patterns returning references from a conditional) that today's checker rejects. Code that fails to compile on one toolchain version can compile on a later one with no source change, and there is no document that says which programs must be accepted.

The borrow checker asks a question with a precise shape: at every program point, for every borrow, is that borrow still live, and does anything conflicting happen while it is? "Live" means "may still be used along some path from here", which is the definition of [[liveness-analysis]] — a backward dataflow problem over a control-flow graph. Ask it on a tree and you cannot express "along some path", so you fall back to a syntactic approximation: the borrow lives to the end of its block.

That approximation is what pre-2018 Rust used, and its failures were famous. A function that borrowed a map to look something up, then wanted to insert on the None branch, was rejected — the borrow was over in every meaningful sense but the block had not ended. Moving the check onto MIR did not make the compiler cleverer about a rule; it replaced the rule with one that could only be stated on a graph.

The consequence engineers actually feel is that borrow errors are reported in terms of paths, not lines: "value borrowed here is still borrowed when used there, because of this branch". That phrasing comes from a dataflow result, and reading it as a dataflow result — which path keeps the borrow alive — is far more productive than reading it as a rule about braces.

Macros run before types exist, and some of them are programs

Macro expansion sits between parsing and HIR, which fixes what a macro can and cannot know. It operates on token trees, so it can see syntax and cannot see types: macro_rules! matches shapes, not meanings, and a macro cannot ask what type an argument has because nothing has computed one yet. That is why macro-generated code produces its errors at the expansion site, after substitution, in the same way template instantiation does in [[templates]].

Procedural macros are a larger commitment. A proc-macro crate is compiled to a dynamic library and *executed by the compiler* during the build of the crate that uses it, taking a token stream and returning one. It is an ordinary Rust program with the compiler's privileges: it can read files, open sockets and consume unbounded time, and it runs on every machine that builds the project, including CI.

Two consequences follow. Build time: derive-heavy crates spend real time running these programs, and it is attributed to the dependent crate rather than to the macro. And trust: a proc-macro dependency is code that executes on your build machine, which puts it in a different risk category from a library that merely runs in your process. See [[toolchain-trust]], and [[hermetic-compilation]] for the containment story.

Where the build time goes

implementationWhich of these dominates is per-project and per-profile: a debug build with sixteen codegen units and no optimization is usually frontend-bound, while a release build with lto = "fat" and one codegen unit is almost always LLVM-bound. The -Z flags are nightly-only and their output format changes. Any statement of the form "Rust builds are slow because X" is a statement about one crate graph and one profile.

Rust builds are slow for three separable reasons, and the fix differs for each. Trait resolution and inference are the frontend cost, and they grow with generic depth — deeply nested iterator chains and heavy where clauses are the expensive shapes. Monomorphization multiplies: a generic function used with twenty types becomes twenty functions, all of which LLVM must then optimize. And LLVM itself is usually the largest single term in a release build, because it is being handed the output of that multiplication.

The crate is the unit of compilation and the unit of parallelism, which cuts both ways. One large crate is one mostly serial frontend job, so splitting it into several buys parallelism and incremental rebuild granularity. But generic code from a dependency is monomorphized in *your* crate — the dependency shipped MIR, not machine code — so splitting does not move that cost anywhere, and a change to a widely used generic still touches everything.

The tooling is unusually good at telling you which of the three you have. cargo build --timings produces an HTML report of per-crate wall time and where the parallelism went; cargo llvm-lines counts LLVM IR lines per generic function and finds the monomorphization bombs; -Z self-profile breaks the frontend down by query. Guessing is unnecessary and, given how different the three fixes are, expensive.

Three build-time costs with three different fixesimplementation
CostWhat it isHow to see itWhat actually reduces it
FrontendInference and trait resolution over generic code-Z self-profile, or cargo check time in isolationSimpler bounds, less generic depth, fewer blanket impls
MonomorphizationOne copy of each generic per substitution reachedcargo llvm-linesA non-generic inner function with a thin generic wrapper
LLVMOptimizing everything monomorphization producedcargo build --timings, and the debug/release gapFewer codegen units in release, more in debug; opt-level per profile
LinkingOne large link of many object filesTime between the last codegen and the finished binaryA faster linker (lld, mold) — often the cheapest single win
Proc macrosPrograms the compiler runs during your buildcargo build --timings shows the macro crate and its dependentsFewer derives on large types; cargo expand to see what they generate

How it works

The steps, in the order the compiler takes them.

  • The crate's source is tokenized and parsed into an AST that still contains macro invocations.
  • Macros are expanded — macro_rules! by matching token trees, procedural macros by compiling the macro crate and running it — interleaved with name resolution, because expansion can introduce new items to resolve.
  • The AST is lowered to HIR, desugaring loops, ?, patterns and method-call syntax into a smaller set of forms.
  • Type inference and trait resolution run over HIR, choosing a concrete impl for every obligation; the result is recorded and used to build THIR.
  • THIR is lowered to MIR: a control-flow graph over locals and places, with drops elaborated, bounds checks and overflow checks inserted, and unwind edges explicit.
  • Borrow checking runs as a dataflow analysis over MIR, computing borrow liveness and rejecting conflicting accesses; const evaluation and some optimizations also run on MIR here.
  • Monomorphization collects every reachable generic instance with concrete substitutions, producing one MIR instance per instantiation.
  • Those instances are lowered to LLVM IR, partitioned into codegen units, optimized by LLVM and emitted as object files, which are then linked with the standard library and any native dependencies.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A borrow error names a branch you were not thinking about, because the analysis is over paths and one path keeps the borrow live past where you expected.
  • A release build takes ten times as long as a debug build and cargo llvm-lines shows one generic function expanded into a hundred thousand lines of IR.
  • A crate compiles for a year and then fails after a dependency adds a blanket impl, producing an inference ambiguity in code that did not change.
  • A build machine takes a network connection during compilation, because a procedural macro in the dependency graph does that, and nothing in the source of your crate mentions it.
  • An error message points inside a macro expansion rather than at the code you wrote, and the actual mistake is an argument you passed three lines above.
  • Adding a small generic helper to a hot module doubles compile time, because it is instantiated for every type in the module and each instance goes through LLVM separately.
  • A binary is far larger than expected and the size is in monomorphized instances of a dependency's generic code, not in anything the project wrote.

When it helps

  • Reading a borrow error as a dataflow result — which path keeps the borrow live — rather than as a rule about scopes, which is the difference between fixing it and rearranging braces until it compiles.
  • Attacking build time with the right tool, since the four costs have four different fixes and guessing wrong wastes days.
  • Understanding why an unsafe block is genuinely dangerous: it suspends the check that establishes the invariant everything downstream, including LLVM's noalias, is entitled to rely on.
  • Explaining why a dependency's generic code costs you compile time even though the dependency was already built.

When it hurts

  • Treating the phase names as stable knowledge. They are compiler internals with no specification behind them, and two of them did not exist a decade ago.
  • Reasoning about Rust performance from source. After desugaring, monomorphization and LLVM, the relationship between an iterator chain and the emitted loop is not visible without looking at the output.
  • Assuming that what compiles today is the language definition. The set of accepted programs has grown with the analysis, and will again.

What it costs

Every one of these is paid by something.

  • Many representations buy checks that are impossible on the form before them — trait resolution needs types, borrow checking needs a graph — and pay with compile time, memory, and a compiler large enough that its own phase list is a subject.
  • Monomorphizing buys direct calls, known layouts and no runtime cost for generics, and pays with LLVM work and binary size proportional to the number of substitutions, plus a cost that lands in the consuming crate rather than the producing one.
  • Checking safety in the frontend buys a guarantee that does not depend on the backend, and pays by making the guarantee only as good as the unsafe code that opts out of it — a single wrong unsafe block invalidates assumptions the optimizer is already relying on.
  • Procedural macros buy extremely powerful code generation with ordinary Rust, and pay with build-time execution of dependency code on every developer and CI machine, plus build time attributed to the wrong crate.
  • Making the crate the compilation unit buys whole-crate optimization and simple module semantics, and pays with a mostly serial frontend per crate and rebuild granularity that is coarser than a file.

What else you could do

What a different compiler or language does instead, and when that is better.

  • A tree-based borrow check — lexical lifetimes, as Rust had before 2018 — is far simpler to implement and rejects a great deal of correct code. That is the trade the move to MIR bought out of.
  • Garbage collection removes the need for the analysis altogether, at the cost of a runtime and unpredictable pauses; Go takes this route — see [[go-pipeline]].
  • Compiling generics by erasure, as Java does, keeps code size constant and makes a dependency's generic code somebody else's build cost, paying with boxing and indirection — see [[type-erasure]].
  • A different backend changes the last third entirely: rustc_codegen_cranelift compiles far faster with less optimization for debug builds, and rustc_codegen_gcc targets GCC's backend for architectures LLVM does not serve well.
  • C++ reaches similar generated code from a similar monomorphization strategy but checks the generic code only at instantiation, so the errors land in the library rather than at the call — see [[templates]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • cargo build --timings produces an HTML report of per-crate compile time and parallelism — the first thing to run on a slow build.
  • cargo llvm-lines --bin x counts generated LLVM IR lines per function, which is how you find a monomorphization bomb.
  • rustc -Z unpretty=hir, -Z unpretty=thir-tree and -Z unpretty=mir dump the intermediate forms; -Z dump-mir=all writes MIR after each pass. All nightly-only.
  • cargo expand shows the source after macro expansion, which turns an incomprehensible derive error into a readable one.
  • cargo rustc -- --emit=llvm-ir writes the LLVM IR; --emit=asm writes assembly. Compiler Explorer does both with a version picker.
  • RUSTFLAGS="-Z self-profile" plus summarize breaks the frontend down by query, which distinguishes trait resolution from everything else.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The borrow checker is a rule about scopes." It is a dataflow analysis over a control-flow graph; scopes were the old approximation, and replacing them is what made the modern rules possible.
  • "MIR is Rust's bytecode." Nothing executes MIR as a program. It is an internal analysis form, and the const evaluator that does interpret it is a compile-time facility, not a runtime.
  • "LLVM makes Rust safe." Every safety guarantee is established before LLVM is involved. LLVM is told about the results — noalias — and relies on them.
  • "My dependency is already compiled, so its code costs me nothing." Its generic code ships as MIR and is monomorphized in your crate, so it costs you compile time and binary size.
  • "A macro can check the type of its argument." Expansion runs before type checking. A macro sees tokens, which is why its errors appear after substitution rather than at the call.

Misconceptions

The claim, and what is actually true.

Rust is safe because it has no undefined behavior.
Rust has undefined behavior, and unsafe is where you promise not to cause it. The guarantee is that safe code cannot reach it, which is only as strong as the unsafe blocks in the program and its dependencies.
The borrow checker runs on the source code.
It runs on MIR, after desugaring, type checking and drop elaboration. That is why its errors talk about paths and drops that are nowhere in the text you wrote.
Rust is slow to compile because it checks so much.
Checking is usually not the largest term. Monomorphization followed by LLVM optimization normally is, which is why cargo check is so much faster than cargo build.
A #[derive] is free — it is just an attribute.
It runs a compiled program during your build that generates code the compiler then processes. Both halves cost time, and the generated code is what cargo expand will show you.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Rust turns your code into several different forms on the way down, and each form exists so the compiler can check one more thing. The important one is MIR, a flowchart of the program, because that is the shape you need to prove that two references never point at the same thing at the same time. Only after all the checking does LLVM turn it into machine code — and by then the safety work is finished.

practical

Read borrow errors as questions about paths: which route through the function keeps this borrow alive past the point you wanted to use the value? For build time, measure before acting — cargo build --timings first, then cargo llvm-lines if a generic function is enormous, then try a faster linker, which is often the cheapest single improvement. When a derive macro produces an incomprehensible error, run cargo expand and read the generated code; the mistake is almost always visible there.

advanced

The structural insight worth taking away is that Rust's safety guarantee is an *upstream* property that a downstream optimizer is then allowed to exploit. Because the borrow checker proved no two live &mut alias, rustc can attach noalias to those parameters, and LLVM may then reorder and eliminate memory operations in ways that would be illegal for a C pointer, where the same information cannot be recovered by [[alias-analysis]] at any cost. That is the whole architecture in one sentence: a proof established seven representations before the optimizer, communicated to it as metadata. It also explains why an incorrect unsafe block is so much worse than an ordinary bug — it does not merely misbehave locally, it invalidates a premise the optimizer already used, and the resulting miscompilation appears somewhere else entirely. Historically this exact interaction was so hard to get right that rustc disabled noalias emission for several releases while LLVM bugs in that path were fixed.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationEvery phase name in this lesson is a rustc internal with no specification behind it, and the list has changed repeatedly: MIR arrived in 2016, THIR replaced HAIR, non-lexical lifetimes shipped with the 2018 edition, and Polonius would replace the borrow-check stage again. No other Rust implementation is obliged to have any of these, and the -Z flags that dump them are nightly-only and unstable.
implementationThe claim that LLVM dominates release build time is what cargo build --timings reports on typical crate graphs with default release settings; it inverts for cargo check, for debug builds with many codegen units, and for crates whose cost is trait resolution rather than code volume. Alternative backends change it entirely — rustc_codegen_cranelift exists precisely to trade optimization for compile speed in debug builds.
specWhat Rust guarantees — that safe code has no data races, no use-after-free and no aliasing &mut — is a language-level promise, and it holds regardless of which internal representation proves it. What is not promised is that any particular correct-looking program is accepted: the analysis is conservative, and the accepted set has grown with every improvement to it.
typicalMonomorphization producing code as fast as a hand-written specialisation is the usual outcome and not a guarantee; the same instruction-cache pressure that can invert the benefit in C++ applies here, and heavy generic use in a cold path costs binary size for nothing. This is a measurement question per program rather than a property of the technique.

If you were asked this in an interview

  • Why does the borrow checker run on MIR rather than on the AST, and what did moving it there make possible?
  • A dependency is already compiled. Why does its generic code still cost you build time?
  • What does rustc tell LLVM that a C compiler cannot, and what proves it?

Connections

DSAdag
Domains that do not exist yet
  • Programming Languages & Runtime Internals — What Rust has instead of a runtime — panics, unwinding, and the allocator
    This lesson ends at the object file. The small amount of runtime support a Rust program does need, and what happens when a panic unwinds through frames whose drops the compiler elaborated on MIR, is the other half of the story and is owned there.
  • Testing & Reliability Engineering — Miri and the discipline of testing unsafe code
    Everything downstream of the borrow check assumes the unsafe blocks upheld their invariants, and only execution-based checking can look for violations. The tool is compiler-adjacent; the testing practice around it belongs there.