Why the Runtime Model Matters
Why This Module Exists
Section titled “Why This Module Exists”The previous seven modules taught you to write Rust: move a value, borrow it, implement a trait, propagate an error, iterate a collection, spawn a thread. That is enough to produce working programs by hand. It is not enough to confidently review Rust you did not write — and a growing share of the Rust in front of you today came from a tool that reproduces shapes it has seen before, not from a person who traced through what the compiler actually does with those shapes.
AI-generated Rust that compiles is not the same claim as AI-generated Rust that is well-formed for the reasons you think. Code that boxes a tiny Copy value “for performance,” a dyn Trait call believed to cost nothing, a Rc<RefCell<T>> reached for out of habit — all of it compiles, all of it runs, and none of the bugs are visible from the syntax alone. Telling correct-by-accident apart from correct-by-design means knowing what the compiler does to your source between typing it and running it. That is what this module is for.
Four Stages, One Program
Section titled “Four Stages, One Program”Every .rs file you write goes through the same pipeline before it becomes a binary:
flowchart LR Source["Your source code AST"] --> MIR["MIR + borrow checker ownership, lifetimes, exclusivity all verified here"] MIR --> Codegen["Monomorphized codegen one specialized copy of generic code per concrete type"] Codegen --> Binary["Machine code the binary you run"]
Your source is first checked for borrow-checker violations against MIR (mid-level intermediate representation) — this is the stage that decides whether E0502, E0499, or a lifetime error fires, entirely before any machine code exists. Code that survives that stage is then monomorphized: every generic function is compiled into one specialized copy per concrete type it’s actually called with, and every dyn Trait call is left as a single shared function reached through a vtable instead. Only after both of those stages does LLVM turn the result into the actual machine code in your binary.
This module’s five lessons walk that pipeline, plus what happens once the binary is actually running: Memory Layout covers what a value actually looks like in memory — stack vs heap, padding, niche optimization. Drop & RAII covers what the compiler inserts at scope exit and why. The Borrow Checker Model goes past the compile errors you’ve already seen into why the MIR stage accepts or rejects the programs it does. Monomorphization & Dispatch is about the codegen stage directly — what “zero-cost” actually means and measures as. The Async Runtime Model covers what async fn actually compiles into, since it is the single biggest gap between “code that reads sequentially” and “what the compiler and an executor actually do with it.”
What the Compiler Says: Caught Before It Ever Reaches Codegen
Section titled “What the Compiler Says: Caught Before It Ever Reaches Codegen”The MIR/borrow-check stage runs before monomorphization and codegen — a program that violates ownership rules never gets far enough to be compiled into machine code at all, no matter how the generic or dispatch choices downstream would have gone:
// @expect-error E0382fn main() { let s = String::from("hello"); let t = s; println!("{}", s);}error[E0382]: borrow of moved value: `s` --> src/main.rs:4:20 |2 | let s = String::from("hello"); | - move occurs because `s` has type `String`, which does not implement the `Copy` trait3 | let t = s; | - value moved here4 | println!("{}", s); | ^ value borrowed here after move |help: consider cloning the value if the performance cost is acceptable |3 | let t = s.clone(); | ++++++++
error: aborting due to 1 previous errorThis is exactly the kind of error The Borrow Checker Model explains from the inside: it is produced by analysis over MIR, not by pattern-matching your original source text — which is also why the diagnostic can point at s’s declared type and suggest a specific fix.
A Program That Proves the Model Is Real
Section titled “A Program That Proves the Model Is Real”std::any::type_name and std::mem::size_of let you ask the compiler, at runtime, what it actually decided about a generic function once it specialized it for each caller:
use std::any::type_name;use std::mem::size_of;
fn describe<T>(_val: &T) { println!("T = {}, size_of::<T>() = {} bytes", type_name::<T>(), size_of::<T>());}
fn main() { describe(&42i32); describe(&"hello"); describe(&3.14f64); describe(&Some(7u8));}T = i32, size_of::<T>() = 4 bytesT = &str, size_of::<T>() = 16 bytesT = f64, size_of::<T>() = 8 bytesT = core::option::Option<u8>, size_of::<T>() = 2 bytesThere is exactly one describe in the source, but four different Ts at the four call sites, and the compiler answers a different, exact, concrete question for each one — size_of is not a runtime computation here, it is a fact baked in during codegen for that specific specialized copy. Monomorphization & Dispatch proves that “one copy per type” claim directly; Memory Layout explains where numbers like 16 (a fat pointer) and 2 (a niche-optimized Option<u8>) actually come from.
use std::any::type_name;use std::mem::size_of;
fn describe<T>(_val: &T) { println!("T = {}, size_of::<T>() = {} bytes", type_name::<T>(), size_of::<T>());}
fn main() { describe(&42i32); describe(&"hello"); describe(&3.14f64); describe(&Some(7u8));}Compiling…
Mental Model
Section titled “Mental Model”Think of compiling a Rust program as three inspectors on an assembly line, each empowered to reject the work outright, followed by a machine that only ever sees what already passed. The first inspector (borrow checking over MIR) checks ownership and lifetimes and can reject the program before any code generation happens at all. The second stage (monomorphization) does not reject anything — it duplicates: every generic function becomes one concrete copy per type it’s used with, while every dyn Trait call stays as a single shared function reached indirectly. Only what survives both stages reaches LLVM, which turns it into the machine code that becomes your binary. None of this is visible in the source text you typed — it’s the difference between what you wrote and what actually runs.
Trade-offs
Section titled “Trade-offs”Understanding this pipeline costs you one more mental model on top of the language’s syntax and semantics you already know. What it buys back is that “why did this fail to compile,” “why is this call slower than that one,” and “why did this run in a different order than I expected” all stop being separate mysteries and become the same question — which stage is responsible — with a specific, learnable answer instead of a fresh guess every time.
Common Misconceptions
Section titled “Common Misconceptions”- “If it compiles, the compiler didn’t have to think very hard about it.” The opposite is often true — the borrow checker rejecting nothing is not the same as it doing nothing; MIR-level analysis runs on every program, successful or not, and it is the reason entire bug classes (data races, use-after-free, iterator invalidation) simply do not reach the codegen stage.
- “Generics and
dyn Traitare basically the same feature with different syntax.” They are resolved at opposite pipeline stages: generics are resolved per call site during monomorphization (zero runtime dispatch cost, more generated code),dyn Traitis resolved once at runtime through a vtable (one indirection per call, one shared function). Monomorphization & Dispatch measures the difference directly. - “Rust has a garbage collector doing cleanup at runtime, like other memory-safe languages.” There is no separate runtime process; the compiler inserts specific, deterministic
dropcalls into the generated code at compile time, at points determined entirely by scope — Drop & RAII covers exactly what gets inserted and when.
Reading AI-generated Code
Section titled “Reading AI-generated Code”Code that “obviously” should be faster or safer based on surface-level reasoning about the source text, without a model of which pipeline stage actually determines the outcome, is a common shape in generated Rust — it compiles, it runs, and the reasoning behind it is simply wrong.
🐛 Spot the bug
// "Box it so it doesn't get copied every time we pass it to a function" -- AI-added commentfn process(counter: &Box<i32>) { println!("count = {}", **counter);}
fn main() { let counter = Box::new(42); process(&counter); process(&counter);}Show the answer
This compiles and runs fine, printing count = 42 twice — and the comment’s reasoning is backwards. i32 is Copy and exactly 4 bytes; passing &i32 directly would already avoid any copy of the boxed data, and it would avoid the heap allocation entirely. Box::new(42) here adds a heap allocation, a pointer indirection on every access (**counter needs two dereferences instead of one), and gains nothing — there was never a large value at risk of an expensive copy in the first place. Memory Layout covers exactly when boxing genuinely helps (a value too large to move around cheaply, or one whose size isn’t known until runtime) versus when it’s pure overhead, as it is here.