Skip to content

Instantly share code, notes, and snippets.

@nihalpasham
Last active April 3, 2026 04:57
Show Gist options
  • Select an option

  • Save nihalpasham/86c32d2ddcddbc85c2095468ed1f079c to your computer and use it in GitHub Desktop.

Select an option

Save nihalpasham/86c32d2ddcddbc85c2095468ed1f079c to your computer and use it in GitHub Desktop.
Rust (mir) compiler bites: Closures — thou shalt not name this struct

Closures Are Just Structs: MIR's Closure Desugaring

When you write |x| x + factor, Rust creates an anonymous struct, implements a trait on it, and calls a method.

Table of Contents

  1. The Code
  2. Dumping the MIR
  3. The Desugaring Chain
  4. Anatomy of the Inlined MIR
  5. The Inlining Barriers
  6. Non-Inlined MIR: The Real Closure Machinery
  7. The RustCall ABI Surprise
  8. Summary: What Closures Actually Require
  9. Key Takeaways


The Code

Four functions. Each uses a closure differently.

fn apply_fn<F: Fn(u32) -> u32>(f: F, x: u32) -> u32 {
    f(x)
}

fn apply_fn_once<F: FnOnce(u32) -> u32>(f: F, x: u32) -> u32 {
    f(x)
}

#[inline(never)]
fn direct_call() -> u32 {
    let factor = 3u32;
    let scale = |x: u32| x * factor;
    scale(10)
}

#[inline(never)]
fn generic_call() -> u32 {
    let factor = 3u32;
    let scale = |x: u32| x * factor;
    apply_fn(scale, 10)
}

#[inline(never)]
fn fnonce_call() -> u32 {
    let factor = 3u32;
    let triple = |x: u32| x * factor;
    apply_fn_once(triple, 10)
}

#[inline(never)] on the *_call functions prevents them from being inlined into main(), so we can inspect each one individually. The helper functions (apply_fn, apply_fn_once) have no annotation — the MIR inliner decides on its own whether to inline them.

Everyone has written closures like these. The question is: what do they become?


Dumping the MIR

Anyone can follow along:

# Unoptimized MIR (shows full closure machinery)
cargo +nightly rustc -- -Zunpretty=mir

# Optimized MIR (shows inlining, constant folding)
cargo +nightly rustc --release -- -Zunpretty=mir

We'll look at three versions of essentially the same closure — |x| x * factor — to see how the MIR changes depending on how the closure is used.

Important: with cargo, you must use --release to see inlining. cargo rustc -- -C opt-level=3 does NOT work — cargo still uses the dev profile regardless of that flag. Without --release, no inlining happens and every closure shows the full machinery. All the "inlined" MIR examples below use --release unless stated otherwise.


The Desugaring Chain

Closures are syntactic sugar. Rust desugars every closure into three pieces:

// What you write:
let factor = 3u32;
let scale = |x: u32| x * factor;
let result = scale(10);

// What rustc creates (conceptually):
struct __closure_1 {
    factor: u32,    // captured variable ("upvar")
}

impl FnOnce<(u32,)> for __closure_1 {
    type Output = u32;
    fn call_once(self, (x,): (u32,)) -> u32 {
        x * self.factor
    }
}

let scale = __closure_1 { factor: 3 };
let result = FnOnce::call_once(scale, (10,));

Three components:

Component What it is MIR evidence
Anonymous struct Fields are captured variables ("upvars") Aggregate(Closure(...), [_1])
Trait impl Fn, FnMut, or FnOnce <{closure@...} as FnMut<(u32,)>>::call_mut(...)
Method call call, call_mut, or call_once Function call terminator in MIR

The term "upvar" means "upward variable" — a variable captured from an enclosing scope. The closure struct's fields ARE the upvars.

Capture Modes

Mode Rust syntax Struct field Trait
By shared ref || ... x ... (read only) x: &T Fn
By mutable ref || ... x += 1 ... x: &mut T FnMut
By value (move) move || ... or inferred x: T FnOnce

Anatomy of the Inlined MIR

Here's what direct_call() looks like in release MIR (cargo +nightly rustc --release -- -Zunpretty=mir):

fn direct_call() -> u32 {
    let mut _0: u32;
    let _1: u32;
    scope 1 {
        debug factor => _1;
        scope 2 {
            scope 3 (inlined direct_call::{closure#0}) {
            }
        }
    }

    bb0: {
        _1 = const 3_u32;
        _0 = const 30_u32;
        return;
    }
}

That's it. One basic block. One constant.

Notice:

  • scope 3 (inlined direct_call::{closure#0}) — the compiler tells us it inlined the closure body. The closure struct, the trait dispatch, everything — gone.
  • _0 = const 30_u32 — the entire closure call scale(10) didn't just become a Mul — it was constant-folded to 30. Rustc computed 10 * 3 at compile time.
  • No closure struct. No Fn::call. No tuple packing. The closure is completely erased.

What about generic_call()?

fn generic_call() -> u32 {
    let factor = 3u32;
    let scale = |x: u32| x * factor;
    apply_fn(scale, 10)  // Fn-bounded generic
}

MIR (release):

fn generic_call() -> u32 {
    let mut _0: u32;
    let _1: u32;
    let _2: {closure@src/main.rs};
    scope 1 {
        debug factor => _1;
        scope 2 {
            debug scale => _2;
        }
    }

    bb0: {
        _1 = const 3_u32;
        _2 = {closure@...} { factor: &_1 };
        _0 = apply_fn::<{closure@...}>(move _2, const 10_u32)
             -> [return: bb1, unwind continue];
    }

    bb1: {
        return;
    }
}

Not identical. The closure struct is constructed and passed to apply_fn. Even without #[inline(never)] on apply_fn, the MIR inliner refuses to inline it. Why? Because Fn-bounded generic functions have unwind cleanup blocks (the closure must be dropped if Fn::call panics), and the MIR inliner considers them too expensive to inline.


The Inlining Barriers

Now pass the same closure through FnOnce:

fn fnonce_call() -> u32 {
    let factor = 3u32;
    let triple = |x: u32| x * factor;
    apply_fn_once(triple, 10)  // FnOnce-bounded generic
}

MIR (release):

fn fnonce_call() -> u32 {
    let mut _0: u32;
    let _1: u32;
    scope 1 {
        debug factor => _1;
        let _2: {closure@src/main.rs};
        scope 2 {
            debug triple => _2;
            scope 3 (inlined apply_fn_once::<{closure@...}>) {
            }
        }
    }

    bb0: {
        _1 = const 3_u32;
        _2 = {closure@...} { factor: &_1 };
        _0 = <{closure@...} as FnOnce<(u32,)>>::call_once(move _2, const (10_u32,))
             -> [return: bb1, unwind continue];
    }

    bb1: {
        return;
    }
}

Two things happened:

  1. scope 3 (inlined apply_fn_once::<...>) — the apply_fn_once wrapper WAS inlined. Its body is simple — FnOnce::call_once consumes the closure, so there are no unwind cleanup blocks.
  2. FnOnce::call_once(move _2, ...) — but the closure's trait method call was NOT inlined. The struct is constructed, the tuple is packed, and a real call_once dispatch remains.

This reveals two inlining barriers, not one:

Scenario Helper inlined? Closure call inlined? Result
Direct call: scale(10) N/A YES const 30_u32
Fn-bounded: apply_fn(scale, 10) NO N/A (blocked) Calls apply_fn(...)
FnOnce-bounded: apply_fn_once(triple, 10) YES NO Calls call_once(...)
FnMut-bounded: apply_fn_mut(scale, 10) NO N/A (blocked) Calls apply_fn_mut(...)

Barrier 1: Fn/FnMut helpers don't inline. apply_fn and apply_fn_mut have unwind cleanup blocks — they must drop the closure if the trait call panics. The MIR inliner considers these too expensive, even without #[inline(never)].

Barrier 2: FnOnce::call_once dispatch doesn't inline. Even after apply_fn_once is inlined (its body has no cleanup — call_once consumes the closure), the call_once trait method itself remains a real function call in the optimized MIR.

Only a direct closure call (scale(10)) fully collapses. The moment you route through any generic helper, some or all of the closure machinery survives in MIR.

Note: In unoptimized MIR (no --release), nothing inlines — every closure shows the full struct + trait dispatch machinery regardless of bound. The barriers above only appear in release builds.


Non-Inlined MIR: The Real Closure Machinery

When the closure ISN'T inlined, you can see the full machinery. Let's look at the closure's own function body — the call_once method:

fn fnonce_call::{closure#0}(_1: &{closure@...}, _2: u32) -> u32 {
    debug x => _2;
    debug factor => (*((*_1).0: &u32));
    let mut _0: u32;
    let mut _3: u32;
    let mut _4: &u32;

    bb0: {
        _4 = copy ((*_1).0: &u32);
        _3 = copy (*_4);
        _0 = Mul(copy _2, move _3);
        return;
    }
}

Reading this:

  • _1: &{closure@...} — the first parameter is a reference to the closure struct. (*_1).0 accesses field 0 (the captured factor reference), then dereferences it.
  • _2: u32 — the second parameter is the argument x. But wait — at the call site we passed a tuple (u32,). MIR unpacked it. More on this in the next section.
  • debug factor => (*((*_1).0: &u32)) — the debug annotation maps factor through a double dereference: field 0 of the closure struct is a &u32, then deref to get the u32.
  • _0 = Mul(copy _2, move _3) — the closure body: x * *self.factor.

A note on naming: fnonce_call::{closure#0}

In the MIR dump you'll see this body listed as fn fnonce_call::{closure#0}. This looks like a nested function inside fnonce_call, but it's not — it's the compiler-generated trait method implementation for this closure. These are two names for the same function:

Context Name
At the call site (trait dispatch) <{closure@src/main.rs:...} as FnOnce<(u32,)>>::call_once
At the definition (function body) fn fnonce_call::{closure#0}

Rustc names the body after where the closure literal appears in source (fnonce_call, closure #0), not after the trait method it implements. There is no separate call_once wrapper — the closure body IS the trait method implementation.

What about closures with no captures?

let double = |x: u32| x * 2;

This closure captures nothing. Its struct is empty — a Zero-Sized Type. In release MIR:

debug double => const ZeroSized: {closure@src/main.rs:58:18: 58:26};

The variable is declared but never assigned. There's nothing to store. Rustc knows the type, emits the debug annotation with const ZeroSized:, and moves on.


The RustCall ABI Surprise

Here's something that surprised us when building the backend. Look at the call site again:

_0 = <{closure@...} as FnOnce<(u32,)>>::call_once(move _2, const (10_u32,))

And the closure body signature:

fn fnonce_call::{closure#0}(_1: &{closure@...}, _2: u32) -> u32

The call site passes a tuple (10_u32,). The body expects unpacked u32. This is the RustCall ABI.

Component What fn_sig says What MIR locals show
Regular function [arg1, arg2, ...] _1=arg1, _2=arg2
Closure (RustCall) [(arg1, arg2, ...)] — a tuple _1=self, _2=arg1, _3=arg2 — unpacked

For a closure |x: u32| x * 3:

  • fn_sig().inputs() returns [(u32,)]1 element (a tuple wrapping the argument)
  • But MIR has _1 = &mut Self, _2 = u322 parameters (self + the unpacked arg)

A backend that trusts fn_sig to count parameters gets the wrong number. The correct count is 1 (self) + tuple_elements.

This also means the backend must unpack the tuple at the call site. The caller passes (u32,), but the callee expects u32. Without tuple unpacking via field extraction, the LLVM types won't match:

expected: func<i32(ptr, i32)>
got:      func<i32(ptr, struct<{i32}>)>

Summary: What Closures Actually Require

Implementing closure support in a MIR backend requires more than "closures are just structs." Here's the full picture:

Feature Why
Struct types Closure = struct with upvars as fields
Struct construction Aggregate(Closure(...), [captured_values])
Field access (_1.0: u32) to read captures inside the body
Closure type translation RigidTy::Closure is a separate type kind from FnDef
RustCall ABI handling fn_sig shows a tuple; MIR locals are unpacked
Tuple unpacking at call sites Caller passes (u32,), callee expects u32
Trait method resolution <{closure} as FnOnce>::call_once must be found and compiled
Associated type aliases FnOnce::Output must resolve to the concrete return type
ZST handling Closures with no captures are zero-sized — declared but never assigned
Name sanitization {closure#0} contains invalid identifier chars ({, }, #)
Mutable references &mut _6 for passing the closure to call_mut

That's 11 distinct features. And most of them are invisible when rustc inlines the closure away.


Key Takeaways

  1. A closure is an anonymous struct + a trait impl + a method call — all three are visible in unoptimized or non-inlined MIR
  2. Inlining hides everything — for direct closure calls, rustc inlines and constant-folds the entire thing away
  3. There are two inlining barriersFn/FnMut helpers don't inline (cleanup blocks), and even when FnOnce helpers do inline, the call_once trait dispatch survives
  4. Closures use the RustCall ABIfn_sig reports a tuple, MIR locals are unpacked. A backend must reconcile the two
  5. ZST closures are declared but never assigned — they're live in the control flow but have no value in the value map
  6. FnOnce::Output is an alias type — the return type of a closure call isn't concrete in the signature; it's an associated type projection that must be resolved
  7. The name {closure#0} is not a valid identifier — backends that use the source name need to sanitize or fall back to the mangled symbol

Reproducing

# Dump optimized MIR for the whole crate (MUST use --release)
cargo +nightly rustc --release -- -Zunpretty=mir

# Dump unoptimized MIR (shows full closure machinery)
cargo +nightly rustc -- -Zunpretty=mir

# Filter for a specific function
cargo +nightly rustc --release -- -Zunpretty=mir 2>&1 | grep -A 50 "fn fnonce_call"

# See the closure body itself
cargo +nightly rustc --release -- -Zunpretty=mir 2>&1 | grep -A 20 "fn.*closure"

Note: cargo rustc -- -C opt-level=3 does NOT produce optimized MIR. Cargo ignores -C opt-level for profile selection — it always uses the dev profile unless you pass --release. This is the #1 gotcha when inspecting MIR.


Source Material

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment