How Rust Runs

Lesson 1 of 5

From Rust to a Binary

"I can remember the exact instant when I realized that a large part of my life from then on was going to be spent in finding mistakes in my own programs." — Maurice Wilkes, Memoirs of a Computer Pioneer

Twenty-three volumes of this Library have taught you languages that run on something. Python has an interpreter. JavaScript has an engine. Java has a virtual machine, C# has a runtime, Go has a scheduler and a garbage collector stitched into every binary it produces. In each case there was a layer between the code you wrote and the processor that ran it, and in each case a good chunk of the volume went on explaining that layer, because the questions that separate a mid-level engineer from a senior one are almost always questions about it.

Rust has no such layer. There is no interpreter to install, no virtual machine to tune, no garbage collector to reason about, and no scheduler deciding when your function gets to continue. rustc reads your source and produces a file the operating system can execute directly, in the same sense that a C compiler does. When that file runs, the instructions executing on the processor are the ones the compiler chose, and there is nothing underneath them that belongs to Rust.

This is the single most important fact about the language, and almost every other thing that feels strange about Rust follows from it. Ownership exists because nobody is coming to free your memory. Lifetimes exist because the compiler has to prove, before the program runs, that a reference will still be valid when it is used — there is no runtime check to fall back on. The borrow checker exists because a data race has to be caught at compile time or not at all. Take away the runtime and you have to put the safety somewhere; Rust puts it in the compiler, and you pay for it in argument.

So this volume opens where the last six opened: with the machine, before the language. This chapter follows a Rust program from source to a running process. We will look at what the compiler computes before the program starts, what ends up inside the binary, why a hello-world executable is eight hundred kilobytes, where values live when nothing is collecting garbage, and what "zero-cost abstraction" means once you have measured it rather than read it on a slide. Everything in Parts II through VIII is downstream of this.

A warning about the shape of this volume, since you will notice it on the first page. There is no Run button here. Rust's compiler is rustc plus LLVM, which is hundreds of megabytes and does not go in a browser, so unlike the Go, PHP, Java and C# volumes, you cannot edit these blocks in place. Every block in this volume was instead compiled and run against a real rustc before it was published, with warnings treated as errors, and the output you see underneath each one is the output it actually produced. Install Rust and run them yourself — rustup takes a few minutes — because reading Rust and arguing with rustc are different skills, and only one of them gets you hired.

The big question

If there is no runtime underneath your program, who does the work that a runtime normally does — and when does it happen?

What the compiler produces, and when

A compiler's job is to move work earlier. Every decision it can make at compile time is a decision the processor does not have to make a billion times at run time, and Rust is unusually aggressive about this. The clearest demonstration is const evaluation: a function marked const fn can be called in a context that must be known at compile time, and the compiler runs it — actually executes it, inside rustc — and puts the answer in the binary.

const fn triangular(n: u64) -> u64 { n * (n + 1) / 2 }

// Worked out by the compiler, not by the program. The binary ships the answer.
const SUM: u64 = triangular(1_000_000);
const LANES: usize = 4;

fn main() {
    println!("sum of 1..=1_000_000 = {SUM}");
    let buckets = [0u32; LANES];
    println!("{} lanes, {} bytes, decided before the program ran",
             buckets.len(), std::mem::size_of_val(&buckets));
}
sum of 1..=1_000_000 = 500000500000
4 lanes, 16 bytes, decided before the program ran

The running program never computes that sum. It never loops to a million. The number 500000500000 is a constant baked into the executable, and the only instruction at run time is the one that loads it. The array length is the same story told about memory: [0u32; LANES] is sixteen bytes of stack because LANES was knowable, and a type whose size is knowable needs no allocation and no bookkeeping.

That is the pattern to hold on to. Rust moves work from run time to compile time wherever it can, and when it cannot, it usually asks you to say what should happen instead of guessing. This is why Rust compiles slowly. It is doing work your program would otherwise do, once, on a machine you are paying for, rather than on every machine your users run it on.

Everyday analogy

A restaurant can take your order, then start sourcing ingredients, or it can prepare everything it can in advance and assemble on demand. The first is flexible and slow at the moment it matters; the second is rigid, requires knowing the menu up front, and serves in ninety seconds. An interpreter is the first kitchen. Rust is the second — which is also why it is so insistent that you tell it the menu, and so unhelpful when you want to decide halfway through service.

What happens between your source and the machine code

It is worth knowing the shape of the pipeline, because two of its stages explain things you will otherwise find mysterious, and one of them is where the entire next Part lives.

rustc first does what every compiler does: turns your text into tokens, then into a syntax tree. Macros are expanded here, which is why a macro error points at a line that does not appear in your file — the compiler is reporting on code the macro generated. The tree is then lowered into HIR, a friendlier form where names have been resolved and the type checker does its work, inferring the types you did not write and rejecting the ones that do not fit.

HIR is then lowered again, into MIR — a deliberately simple control-flow graph where every operation is explicit and all the syntactic sugar is gone. MIR is where the borrow checker runs. This matters more than it sounds: borrow checking is not a syntax rule and not a type rule, it is a dataflow analysis over a graph of basic blocks, asking at every program point which references are still live. That is why borrow errors talk about where a value "is still borrowed here" and point at three different lines at once — the analysis is reasoning about paths through your program, not about the shape of a statement. It is also why the rules got noticeably more permissive when non-lexical lifetimes arrived: nothing about the language changed, the analysis just got more precise about where a borrow actually ends.

Only after MIR passes does the compiler generate LLVM IR and hand off to LLVM for optimisation and machine-code generation, then to a linker to produce the final executable. Two practical consequences follow. First, most of Rust's famously good error messages come from the earlier stages, which is why they can talk about your variables and your lifetimes rather than about registers. Second, a large share of your compile time is LLVM's, not Rust's — which is why the monomorphisation cost later in this chapter hurts so much, since each specialised copy is another function for LLVM to optimise.

One honest caveat on "no runtime", since a careful reader will find it. A Rust binary does contain a small amount of code that is not yours: a shim that sets up the stack and arguments before calling main, and the machinery that unwinds the stack when a panic propagates, running destructors on the way. That is a few kilobytes of support code, and it can be removed — panic = "abort" drops the unwinding machinery, and #![no_std] drops almost everything else for embedded targets. The claim is not that a Rust binary is literally nothing but your instructions. It is that there is no service running alongside your program, making decisions about it, while it executes.

There is no runtime to install

Compile a Rust program and you get a file. Copy that file to another machine of the same shape and it runs. Nothing else needs to be there — no interpreter, no JVM, no dotnet package, no shared library you forgot to ship. This is the property that put Rust into operating systems, command-line tools and embedded devices, and it is worth looking at the actual numbers rather than taking it on faith.

A hello-world program compiled with rustc 1.99.1 on this machine produces a 4,967 KB executable. That number surprises people, and it is usually where someone says that Rust binaries are bloated. But almost all of it is debugging symbols. Strip them and the same program is 852 KB. Now add real work — a HashMap, an iterator chain, string splitting, sorting — and the stripped binary is 870 KB. Eighteen kilobytes more.

That ratio is the whole lesson. The baseline is not your code; it is the standard library being linked into your executable instead of sitting on the user's machine. Once you have paid it, using more of the standard library costs almost nothing, because the compiler only includes what you actually reference. You are trading disk space for the absence of a deployment problem, and on anything that is not a microcontroller that is an excellent trade.

Having no runtime also shows up in the shape of your data. In a language with a virtual machine, every object carries a header the runtime needs — a type pointer, a mark bit, a lock word. In Rust a value is its fields, and nothing else.

use std::mem::size_of;

struct Point { x: i32, y: i32 }

fn main() {
    println!("bool      {}", size_of::<bool>());
    println!("char      {}", size_of::<char>());
    println!("i32       {}", size_of::<i32>());
    println!("f64       {}", size_of::<f64>());
    println!("&i32      {}", size_of::<&i32>());
    println!("Box<i32>  {}", size_of::<Box<i32>>());
    println!("[i32; 4]  {}", size_of::<[i32; 4]>());
    println!("Point     {}", size_of::<Point>());

    let p = Point { x: 3, y: 4 };
    println!("a Point holding ({}, {}) is {} bytes and nothing else",
             p.x, p.y, size_of::<Point>());
}
bool      1
char      4
i32       4
f64       8
&i32      8
Box<i32>  8
[i32; 4]  16
Point     8
a Point holding (3, 4) is 8 bytes and nothing else

Two numbers deserve comment. char is four bytes because a Rust char is a Unicode scalar value, not a byte and not a UTF-16 code unit — a decision that saves you an entire category of bug and costs you three bytes. And Point is eight: two i32 fields and no header. A Point in a managed language would typically be twenty-four bytes on the heap with sixteen of them belonging to the runtime. Here there is no runtime to pay.

Where this came from

Rust began at Mozilla in 2006 as Graydon Hoare's personal project, and the motivating problem was concrete rather than academic: Firefox was a C++ program whose crash reports and security advisories were dominated by memory errors — use-after-free, double-free, buffer overrun, data races between threads. The goal was never to invent a new way to think about programs. It was to keep C++'s performance and its absence of a runtime, while making that specific family of bugs impossible to write. Almost every design decision in this volume reads differently once you know it was made by people reading crash reports.

Paying nothing for the abstraction

"Zero-cost abstraction" is the phrase Rust inherited from C++, and it means something precise: you should not be able to hand-write a faster version of what the abstraction does. Not "it is fast". Not "the overhead is small". The claim is that the abstraction compiles to what you would have written anyway.

The cleanest proof is what Rust does with Option. An Option<T> is an enum with two variants — Some(T) and None — so a naive implementation needs a tag saying which variant it holds, plus the payload. That would make Option<Box<i32>> bigger than Box<i32>, and wrapping a pointer in a null-check would cost memory. Measure it instead.

use std::mem::size_of;

fn main() {
    println!("Box<i32>         {}", size_of::<Box<i32>>());
    println!("Option<Box<i32>> {}", size_of::<Option<Box<i32>>>());
    println!("&i32             {}", size_of::<&i32>());
    println!("Option<&i32>     {}", size_of::<Option<&i32>>());
    println!("i32              {}", size_of::<i32>());
    println!("Option<i32>      {}", size_of::<Option<i32>>());
}
Box<i32>         8
Option<Box<i32>> 8
&i32             8
Option<&i32>     8
i32              4
Option<i32>      8

Option<Box<i32>> is eight bytes — exactly the size of the Box it wraps. The compiler knows a Box can never be null, so it uses the all-zeroes bit pattern, which is otherwise impossible, to mean None. This is called a niche optimisation: the tag is stored in a value the payload could never take. You get a type the compiler forces you to unwrap, and it occupies not one bit more than the raw pointer a C program would have used and forgotten to check.

The last line shows the honest other half. Option<i32> is eight bytes, not four, because every bit pattern of an i32 is a valid i32 — there is no spare value to steal — so the compiler must add a tag and then pad for alignment. Zero-cost is a claim about particular abstractions in particular situations, not a magic spell, and an engineer who can say exactly when it holds is worth more than one who repeats the slogan.

The engineer's notebook

I expected Option<i32> to be five bytes before I ran this — a four-byte payload and a one-byte tag — and I would have written that number into the prose if I had trusted my recollection. It is eight, because the tag forces the whole struct to align to four and then round up. The lesson is not that I got a number wrong; it is that the wrong number was plausible, which is exactly the kind of claim that survives review and ends up being quoted back at you in an interview. Every size in this chapter was printed by a program rather than remembered.

Nobody is coming to clean up

In a garbage-collected language, the question "when is this memory freed?" has no useful answer. Some time after the last reference goes away, when a collector decides to look. That uncertainty is the price of never thinking about it, and for most programs it is a good deal.

Rust answers the question exactly. A value is dropped when it goes out of scope, in reverse order of declaration, deterministically, at a point you can see in the source.

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("dropping {}", self.0);
    }
}

fn main() {
    let _outer = Noisy("outer");
    {
        let _a = Noisy("a");
        let _b = Noisy("b");
        println!("-- leaving the inner block --");
    }
    println!("-- leaving main --");
}
-- leaving the inner block --
dropping b
dropping a
-- leaving main --
dropping outer

Read the order carefully. b is dropped before a, because the last thing created is the first thing destroyed — the same discipline as a stack. The inner values are gone at the closing brace, before the next line of main runs. And outer is dropped at the end of main, after everything else.

This is not a garbage collector being prompt. There is no collector. The compiler inserted calls to drop at the points where it proved the values die, and the generated code runs them like any other function call. That is why Rust can manage files, sockets, locks and database connections with the same mechanism it uses for memory: the type's cleanup runs at a known instant, so a MutexGuard releasing a lock and a File closing a handle are the same idea as freeing a buffer.

It is also why ownership has to exist. The compiler can only insert that drop call if it knows exactly one place is responsible for the value. Two owners would mean two drops and a double-free; no owner would mean a leak. Part II is about that constraint, and it should now read as a consequence rather than a rule.

In the real world

This is the property that gets Rust into systems with hard latency budgets. A garbage collector does its work at a time of its own choosing, which is fine for a web request and not fine for a trading system, an audio pipeline or a flight controller, where a ten-millisecond pause at the wrong moment is the whole failure. Teams in those positions have historically written C or C++ and accepted the memory bugs as a cost of doing business. Rust's offer is that they no longer have to, and it is the reason the Linux kernel, Android and Windows have all taken Rust code in rather than because anyone found the syntax charming.

One function in the source, many in the binary

The last piece of compile-time work worth seeing here is what Rust does with generics, because it explains both the speed and the compile times.

When you write a generic function, the compiler does not produce one function that works for every type by boxing values or looking types up at run time. It produces a separate, specialised copy of that function for each concrete type you actually use — a process called monomorphisation. Write largest once, call it with i32 and f64, and the binary contains two largest functions, each compiled as if you had written it by hand for that type.

use std::any::type_name;

fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
    let mut best = items[0];
    for &it in items { if it > best { best = it; } }
    best
}

fn main() {
    let ints = [3, 17, 4];
    let floats = [2.5f64, 0.5, 9.75];
    println!("largest ints   = {}", largest(&ints));
    println!("largest floats = {}", largest(&floats));
    // One function in the source; the compiler emits one copy per type used.
    println!("instantiated for {}", type_name::<i32>());
    println!("instantiated for {}", type_name::<f64>());
}
largest ints   = 17
largest floats = 9.75
instantiated for i32
instantiated for f64

The comparison inside each copy is a single machine instruction on the appropriate register. There is no dispatch, no indirection, nothing to inline through. A generic function in Rust is exactly as fast as the hand-written version, which is the strongest form of the zero-cost claim in the language.

The bill arrives in two places, and both are visible in real projects. Compile times grow, because the compiler is generating and optimising a copy per instantiation rather than one shared function. And binaries grow, because those copies are all in there. A library with heavy generic machinery can contribute surprisingly many kilobytes of near-identical code. Part III returns to this with dyn Trait, which trades the speed back for one shared implementation — and knowing which of the two you want, and why, is one of the more reliable senior-level questions in a Rust interview.

Common Mistakes

  1. Believing the hello-world binary size is your code. Nearly five megabytes sounds alarming until you strip the symbols and find 852 KB, then add a HashMap and an iterator chain and find 870 KB. The baseline is the standard library being linked in instead of installed on the user's machine. Judge the cost by the slope, not the intercept.

  2. Treating "no runtime" as "no standard library". Rust has a large, excellent standard library; what it lacks is a runtime service — no interpreter, no collector, no scheduler. The two get conflated constantly, and the conflation makes people think Rust is more primitive than it is.

  3. Expecting Option<T> to always be free. The niche optimisation applies when the payload has an impossible bit pattern to steal, which is true for references and Box and false for i32. Saying "Option is free in Rust" is the kind of half-right claim that falls apart under one follow-up question.

  4. Assuming drop order is declaration order. It is the reverse. Values are destroyed last-created-first, like unwinding a stack, and code that depends on two values' cleanup interacting will do the opposite of what you expect if you get this backwards.

  5. Thinking Drop is a finaliser. A finaliser runs eventually, if at all, at the collector's convenience. Drop runs at a specific instant the compiler chose and you can point to. Anything you learned about not relying on finalisers does not transfer.

  6. Reaching for const fn as an optimisation habit. const evaluation is powerful where a value genuinely must be known at compile time — array lengths, lookup tables. Marking functions const reflexively constrains what you can write inside them for no gain, because the optimiser already folds ordinary constants.

  7. Blaming the language for compile times without understanding monomorphisation. Rust is slow to compile partly because it is generating a specialised copy of every generic for every type you use. That is a direct purchase of run-time speed, and it is controllable — but only if you know that is what you are paying for.

  8. Measuring a debug build and drawing conclusions. A debug binary has overflow checks on, no inlining to speak of, and no optimisation. Performance numbers from it are not slightly wrong, they are meaningless. Every timing claim in this volume is about a release build, and so should yours be.

  9. Reading size_of output as universal truth. These numbers are for a 64-bit target. Pointers are eight bytes here and four on a 32-bit microcontroller, and layout for a struct without #[repr(C)] is explicitly not guaranteed. Print the size on the target you care about rather than quoting this chapter.

  10. Skipping the install because the blocks are static. This volume cannot give you a Run button, which makes it the one volume where reading without running leaves the most on the table. Arguing with rustc is the skill. The text can only show you the argument.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What does it mean to say Rust has no runtime, and what does a Rust binary need on the machine that runs it?
Model answer — It means there is no interpreter, virtual machine, garbage collector or scheduler sitting underneath your code at execution time. The compiler produces a native executable, and the instructions running on the processor are the ones it generated. On the target machine you need the operating system and whatever system libraries you dynamically linked against, and that is all — no language installation. That is why Rust is used for command-line tools and system components, where asking a user to install a runtime first is not an option. It does not mean Rust is missing a standard library; the standard library is large and gets linked into your binary rather than living on the user's machine.
Intermediate
A colleague says "Option is free in Rust". When is that true, when is it false, and why?
Model answer — It is true when the wrapped type has a bit pattern it can never legally hold, which the compiler can then use to represent None. A reference and a Box can never be null, so Option of either is exactly the size of the pointer — that is a niche optimisation. It is false when every bit pattern of the payload is valid. Option of i32 is eight bytes rather than four, because there is no spare value to mean None, so the compiler adds a discriminant and then pads to the alignment. So the right statement is that Option is free for types with a niche and costs a word for types without one. The useful follow-up is that this is checkable rather than a matter of belief — size_of will tell you in one line.
Advanced
Your team's Rust service has compile times that have grown to several minutes and a binary that has doubled. Walk me through what is likely happening and what you would trade.
Model answer — My first hypothesis is monomorphisation. Generic code produces a specialised copy per concrete type, so a widely-used generic instantiated over many types multiplies both the work the compiler does and the code in the output, and heavy use of generic library machinery amplifies it. I would confirm before acting — cargo's timing output to find which crates and functions dominate, and a symbol-size breakdown of the binary rather than guessing. The main lever is converting hot generic boundaries to dynamic dispatch with dyn Trait, which gives one shared implementation at the cost of an indirect call and lost inlining. That trade is good at an architectural seam called once per request and bad in an inner loop. Other levers are splitting the crate so rebuilds are incremental, and turning off default features. What I would not do is reach for dyn Trait everywhere on principle, because then I have paid Rust's compile-time cost and given away the run-time speed it bought.
Company lens — how the same topic gets asked
micro1Practical and verbal: why would you pick Rust for a CLI tool over Node or Python, and what does the deployment story look like? They want the single-binary answer and evidence you have actually shipped one.
GooglePushes on the cost model. Expect to be asked what monomorphisation does to code size and instruction cache, and to reason about when a shared dyn implementation beats a specialised one.
MetaInterested in migration reasoning: given an existing C++ service, what does Rust actually buy, and which bug classes does it not address? Answering "memory safety" without naming what remains — logic errors, deadlock, leaks — reads as slogan-level.
AmazonOperational framing. Deterministic destruction versus a collector, and what that means for tail latency and for releasing non-memory resources like connections and file handles under load.
Hands-on

Predict before you measure. Write down your guess for each of the eight sizes below, then install Rust, run the program, and see which ones you got wrong. The three worth thinking hardest about are the enum, the Option that wraps it, and the struct whose fields are a u8 followed by a u32 — the last one is not five bytes, and working out why will teach you more about layout than any amount of reading. Then change the enum: give Circle a second f64 field and re-run. Did Shape grow? Did Option of Shape grow with it? Explain both answers before you accept them.

use std::mem::size_of;

// Predict each number BEFORE you run this. Write your guess down first.
enum Shape {
    Circle(f64),
    Rect { w: f64, h: f64 },
    Unit,
}

struct Wrapper(u8, u32);

fn main() {
    println!("f64              {}", size_of::<f64>());
    println!("Shape            {}", size_of::<Shape>());
    println!("Option<Shape>    {}", size_of::<Option<Shape>>());
    println!("Wrapper          {}", size_of::<Wrapper>());
    println!("(u8, u32)        {}", size_of::<(u8, u32)>());
    println!("Vec<u8>          {}", size_of::<Vec<u8>>());
    println!("String           {}", size_of::<String>());
    println!("&str             {}", size_of::<&str>());

    let shapes = [Shape::Circle(1.0), Shape::Rect { w: 2.0, h: 3.0 }, Shape::Unit];
    let areas: Vec<f64> = shapes.iter().map(|s| match s {
        Shape::Circle(r) => 3.14159 * r * r,
        Shape::Rect { w, h } => w * h,
        Shape::Unit => 0.0,
    }).collect();
    println!("areas            {areas:?}");

    let w = Wrapper(1, 2);
    println!("wrapper holds    ({}, {})", w.0, w.1);
}
f64              8
Shape            24
Option<Shape>    24
Wrapper          8
(u8, u32)        8
Vec<u8>          24
String           24
&str             16
areas            [3.14159, 6.0, 0.0]
wrapper holds    (1, 2)

Wrapper is eight rather than five because a u32 must sit on a four-byte boundary, so three bytes of padding follow the u8. Option<Shape> is the same size as Shape because Shape already has a tag with spare values in it, and the compiler folds None into one of them — the niche optimisation again, now applied to your own type rather than a library one.

Summary

  • Rust produces a native executable with no runtime underneath it — no interpreter, no virtual machine, no garbage collector, no scheduler — so the instructions on the processor are the ones the compiler chose.
  • Almost everything else about the language follows from that absence: ownership, lifetimes and the borrow checker exist because safety has to be established before the program runs rather than policed while it does.
  • The compiler moves work earlier wherever it can, and const fn is the clearest case: a value computed inside rustc is shipped as a constant, so the running program never does the work.
  • Binary size is dominated by the standard library being linked in, not by your code — 852 KB stripped for hello-world, 870 KB once a HashMap, sorting and iterators are added.
  • A Rust value is its fields and nothing else, with no runtime header, which is why a two-field Point is eight bytes rather than the twenty-four it would occupy under a managed runtime.
  • Zero-cost abstraction is a specific, checkable claim, not a slogan: Option<Box<i32>> is the size of the Box because of a niche optimisation, while Option<i32> costs a word because an i32 has no spare bit pattern.
  • Values are dropped deterministically in reverse order of creation, at points the compiler inserted, which is what lets the same mechanism manage memory, files, sockets and locks.
  • Deterministic destruction is the property that buys predictable tail latency, and it is the main reason Rust reaches systems where a collector pause is a failure rather than a slowdown.
  • Generics are monomorphised into a specialised copy per concrete type, which is where the run-time speed comes from and where the compile times and binary growth go.
  • Measure rather than recall: every size and every number in this chapter was printed by a program, and the one I was most confident about guessing — Option<i32> — was the one I had wrong.

In the next chapter, Cargo, Crates and the Build, we move up one level from the compiler to the build system: what a crate actually is, how cargo turns a dependency list into a compilation plan, why the distinction between a binary crate and a library crate shapes your project layout, and what the lock file is really promising you.

Editorial reflection

Wilkes describes the instant he realised he would spend his life finding mistakes in his own programs, and every language in this Library is a different answer to that sentence. Python says the mistakes are cheap to find because the cycle is fast. Java says a runtime will catch the worst of them and keep running. Go says keep the language small enough that fewer are available to make. Rust's answer is the most demanding and the least comfortable: move the finding to compile time, and accept that you will argue with a compiler every day in exchange for a class of bug that cannot reach production. You are about to spend a volume learning why that argument is worth having. It is reasonable to still conclude, for a given project, that it is not — but you should reach that conclusion having understood the offer rather than having bounced off the syntax.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.