| |

Rust 109 🦀 Monomorphization vs Dynamic Dispatch Code Generation

⏱️ Estimated read: 12 min | |

Rust offers two mechanisms for polymorphism, and the choice between them determines what the compiler generates. Monomorphization produces a separate copy of the function for every concrete type it is called with, enabling inlining and constant propagation at the cost of code size. Dynamic dispatch produces a single copy that consults a vtable at runtime, trading the optimizer’s visibility for smaller binaries. The decision is not philosophical; it is a measurement of whether the hot path needs the optimization and whether the binary can afford the duplication.

This chapter covers the code generation consequences of both mechanisms. It begins with the monomorphization process as rustc implements it—the Instance type, the on-the-fly substitution, and the codegen unit partitioning that balances compilation time against optimization. It then explains the vtable layout that dyn Trait produces and the indirect call that follows. The final sections address the cases where monomorphization’s costs outweigh its benefits, the #[inline] attribute’s role in cross-crate inlining, and the decision criteria that separate the two in practice.


Key point: Monomorphization is not free. Every distinct set of generic arguments produces a new instance, and every instance is code that must be compiled and linked. The question is whether the hot path’s speed justifies the binary’s size.


Why the two mechanisms exist

The static dispatch advantage. When the compiler knows the concrete type at the call site, it can inline the callee into the caller. Inlining is the enabler for constant propagation, dead code elimination, and the other optimizations that LLVM performs within a function body. A generic function used with u8 and String produces two specialized functions, and each call site is replaced with a direct call to the appropriate one . The result is code that is as fast as if the programmer had written the specialized version by hand.

The dynamic dispatch advantage. A trait object erases the concrete type. The caller holds a data pointer and a vtable pointer, and the vtable supplies the function pointer for each method . The call is indirect, and the optimizer cannot see through it to inline the target. But the function is compiled once, not once per type, which means the binary does not grow with the number of implementors . A Vec<Box<dyn Draw>> can hold a button, a label, and a user-defined widget without the compiler knowing any of their concrete types.

The code generation boundary. Monomorphization is the default for generics and impl Trait. The compiler substitutes the concrete types into the generic definition and produces a new instance for each combination. The rustc_middle::ty::instance::Instance type represents this: a monomorphized InstanceDef coupled with the substitutions, and codegen performs the substitution as it runs . No monomorphized MIR is ever created as a separate artifact; the instance is the handle, and codegen does the work.


a. Monomorphization and the codegen unit partitioning

The monomorphization process is on-the-fly. When codegen encounters a generic function call with concrete arguments, it resolves the Instance and generates the specialized code. The Instance is the pair of the generic definition and the substitution array, and the codegen backend applies the substitution to the MIR body as it generates machine code .

The codegen unit partitioning is where the compilation time trade-off is managed. rustc_monomorphize::partitioning takes the complete set of monomorphizations and produces codegen units, which are the modules that LLVM compiles and optimizes . The heuristic is two codegen units per source-level module: one for the non-generic (“stable”) code, and one for the monomorphized instances (“volatile” code). The reason is incremental compilation: a change to a generic function body invalidates the codegen unit containing its instances, but not the non-generic code in the same module. A change to a reference to a generic function can introduce a new instance, which invalidates the instance codegen unit but leaves the stable code untouched .

The partitioning is a trade-off between LLVM’s optimization scope and incremental compilation’s granularity. Bigger codegen units give LLVM more code to optimize across, but a change to any function in the unit forces recompilation of the whole unit. Smaller codegen units improve incremental compilation but block cross-function inlining. The heuristic is a compromise: the stable and volatile split reduces the blast radius of a generic reference change without fragmenting the code so much that inlining becomes impossible .

The #[inline] attribute interacts with this partitioning. The partitioner only considers functions marked #[inline] for cross-codegen-unit inlining, because it cannot predict which functions LLVM will actually inline. A function without the attribute is placed in its own codegen unit and is not available for inlining in other units . This is why the standard library marks many small functions with #[inline]: it is the signal that the function should be available across crate boundaries.


b. Dynamic dispatch and the vtable

A dyn Trait reference is a fat pointer. It contains two words: a data pointer to the concrete value, and a vtable pointer to the virtual method table for the implementation . The vtable is a struct of function pointers, one for each method in the trait, plus the size, alignment, and destructor for the erased type .

The call sequence is: load the vtable pointer from the fat pointer, load the function pointer from the vtable at the method’s offset, and call it. The indirect call is the runtime cost. The optimizer cannot inline through it because the target is not known at compile time, which means the method body cannot be optimized in the context of the caller . The cost is usually a few cycles for the indirect branch plus the loss of inlining, and for most code it is negligible. It becomes significant in tight loops where the method is called millions of times and the inlining would have enabled further optimization.

The vtable is created once per (Type, Trait) pair. The compiler emits it alongside the implementation, and the trait object coercion attaches its address to the data pointer. The size and align fields exist for operations that need to know the layout of the erased type, such as Box<dyn Trait> deallocation . The destructor field is what allows an owning trait object to drop the concrete value correctly.

The dyn compatibility rules—formerly called object safety—determine which traits can be used as trait objects. A trait with generic methods, methods returning Self, or associated constants cannot be made into a trait object, because the vtable cannot represent an unbounded set of monomorphizations . This is the compiler’s enforcement of the boundary: if the trait’s methods require monomorphization, the trait cannot be dynamically dispatched.


Complete Example Session

// ============================================
// PART 1: THE GENERIC FUNCTION
// ============================================
// A generic function with a trait bound.

fn do_something<T: MyTrait>(x: T) {
    x.method();
}

// ============================================
// PART 2: THE MONOMORPHIZED INSTANCES
// ============================================
// Two concrete types produce two instances.

struct A;
struct B;
impl MyTrait for A { fn method(&self) {} }
impl MyTrait for B { fn method(&self) {} }

do_something(A);  // Instance: do_something<A>
do_something(B);  // Instance: do_something<B>

// ============================================
// PART 3: THE TRAIT OBJECT
// ============================================
// The same trait, dynamically dispatched.

fn do_something_dyn(x: &dyn MyTrait) {
    x.method();
}

// ============================================
// PART 4: THE FAT POINTER
// ============================================
// &dyn MyTrait is two words: data + vtable.

let a = A;
let obj: &dyn MyTrait = &a;
// obj = (ptr_to_a, ptr_to_vtable_for_A)

// ============================================
// PART 5: THE VTABLE
// ============================================
// The vtable contains function pointers.

// Vtable for A:
//   drop_in_place: fn(*mut A)
//   size: 0
//   align: 1
//   method: fn(&A)

// ============================================
// PART 6: THE INDIRECT CALL
// ============================================
// The call loads the vtable, then the fn pointer.

// obj.method()
// → load vtable pointer from obj
// → load method pointer from vtable
// → call method pointer

// ============================================
// PART 7: THE CODEGEN UNIT PARTITIONING
// ============================================
// Stable code and monomorphized instances
// are placed in separate codegen units.

// Module foo:
//   foo-stable: non-generic functions
//   foo-volatile: monomorphized instances

// A new reference to a generic function
// invalidates only the volatile unit.

// ============================================
// PART 8: THE INLINE ATTRIBUTE
// ============================================
// #[inline] makes a function available
// for cross-codegen-unit inlining.

#[inline]
fn small_helper() {}

// Without #[inline], the partitioner
// does not distribute the function to
// other codegen units.

// ============================================
// PART 9: THE TRADE-OFF
// ============================================
// Monomorphization: faster, larger binary.
// Dynamic dispatch: smaller, slower calls.

// The choice depends on the hot path.
// Profile before deciding.

// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// Monomorphization duplicates code for speed.
// Dynamic dispatch shares code for size.
// The compiler generates what the programmer chooses.

fn main() {
    do_something(A);
    do_something(B);

    let a = A;
    do_something_dyn(&a);
}

The ten parts build a comparison of the two mechanisms: the generic function, the monomorphized instances, the trait object, the fat pointer, the vtable, and the indirect call. The pattern is consistent: the compiler generates code according to the dispatch mechanism the programmer chose.


Quick Reference

Monomorphization

AspectValue
TriggerGenerics, impl Trait
Code generatedOne instance per concrete type
Call typeDirect
InliningPossible
Binary sizeGrows with type count
Compilation timeGrows with instance count

Dynamic Dispatch

AspectValue
Trigger&dyn Trait, Box<dyn Trait>
Code generatedOne function per trait method
Call typeIndirect (vtable)
InliningBlocked
Binary sizeIndependent of implementor count
Runtime costIndirect branch + lost optimization

Vtable Layout

FieldPurpose
drop_in_placeDestructor for the concrete type
sizeSize of the concrete type
alignAlignment of the concrete type
method_1..nFunction pointers for each trait method

Codegen Units

UnitContentsInvalidated By
module-stableNon-generic codeBody/signature change
module-volatileMonomorphized instancesNew reference to generic

Best Practices

✅ Do This:

// Use generics for hot paths and homogeneous data.
fn process<T: Trait>(x: T) { x.method(); }                            // ✅
// Use dyn for heterogeneous collections.
let items: Vec<Box<dyn Draw>> = vec![Box::new(Button), Box::new(Label)]; // ✅
// Mark small cross-crate functions #[inline].
#[inline]
pub fn len(&self) -> usize { self.data.len() }                        // ✅
// Measure before switching from dyn to generic.
// The indirect call is often negligible.                              // ✅

❌ Don’t Do This:

// Don't use Box<dyn Trait> in a hot inner loop without profiling.
for _ in 0..1_000_000 { obj.method(); } // indirect each iteration   // ❌
// Don't assume generics always produce faster code.
// Code bloat can hurt instruction cache.                            // ❌
// Don't forget the dyn compatibility rules.
trait T { fn make() -> Self; } // not dyn-compatible                 // ❌

Common Pitfalls

PitfallWhy It HappensFix
Binary size explosionGeneric function instantiated with many typesUse dyn for cold paths
Slow indirect callsHot loop uses trait objectsSwitch to generics or enum
Trait not dyn-compatibleGeneric methods or Self returnRemove the method or use generics
Cross-crate inlining missingFunction not marked #[inline]Add the attribute
Compilation time growthMany monomorphized instancesReduce generic parameter count

Real-World Examples

1. Monomorphized Function

fn foo<T: Trait>(x: T) { x.method(); }

2. Trait Object Call

fn foo(x: &dyn Trait) { x.method(); }

3. Fat Pointer

let obj: &dyn Trait = &value; // (data, vtable)

4. Vtable Method Call

// vtable[method_offset](data)

5. Inline Attribute

#[inline]
pub fn small() {}

6. Codegen Unit Split

// stable: non-generic
// volatile: monomorphized

7. Heterogeneous Collection

let v: Vec<Box<dyn Draw>> = vec![...];

8. Enum for Closed Set

enum Shape { Circle(Circle), Square(Square) }

9. dyn Compatibility

// trait with generic method: not dyn-compatible

10. Profiling the Choice

// cargo flamegraph to see the indirect call cost

Visual

Monomorphization

┌─────────────────────────────────────────────────────────┐
│  fn process<T: Trait>(x: T)                             │
│         │                                               │
│    ┌────┴────┐                                          │
│    ▼         ▼                                          │
│  process<A>  process<B>                                 │
│  (instance)  (instance)                                 │
│         │         │                                     │
│         ▼         ▼                                     │
│  call site A  call site B                               │
│  direct       direct                                    │
│                                                         │
│  Each instance is separate code.                        │
│  Inlining is possible at each call site.                │
└─────────────────────────────────────────────────────────┘

Dynamic Dispatch

┌─────────────────────────────────────────────────────────┐
│  fn process(x: &dyn Trait)                              │
│         │                                               │
│         ▼                                               │
│  one function                                           │
│         │                                               │
│         ▼  x.method()                                   │
│  load vtable ptr from x                                 │
│  load method fn ptr from vtable                         │
│  call fn ptr                                            │
│                                                         │
│  One copy of process.                                   │
│  The vtable decides which method runs.                  │
└─────────────────────────────────────────────────────────┘

The Vtable

┌─────────────────────────────────────────────────────────┐
│  &dyn Trait                                             │
│  ┌─────────────┬─────────────┐                          │
│  │ data ptr    │ vtable ptr  │                          │
│  └─────────────┴─────────────┘                          │
│         │             │                                 │
│         ▼             ▼                                 │
│  ┌─────────┐   ┌───────────────────────────┐           │
│  │ value   │   │ drop_in_place             │           │
│  └─────────┘   │ size                      │           │
│                │ align                     │           │
│                │ method_1  ← call this     │           │
│                │ method_2                  │           │
│                └───────────────────────────┘           │
└─────────────────────────────────────────────────────────┘

Codegen Unit Partitioning

┌─────────────────────────────────────────────────────────┐
│  Source module foo                                      │
│         │                                               │
│    ┌────┴────┐                                          │
│    ▼         ▼                                          │
│  foo-stable  foo-volatile                               │
│  (non-generic) (monomorphized)                          │
│                                                         │
│  Change to generic body:                                │
│  → invalidates foo-volatile only                        │
│  → foo-stable unaffected                                │
│                                                         │
│  New reference to generic:                              │
│  → invalidates foo-volatile only                        │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
MonomorphizationOne instance per concrete type
Dynamic dispatchOne function, vtable at runtime
Fat pointerData pointer + vtable pointer
Vtable contentsDrop, size, align, method pointers
Codegen unitsStable + volatile per module
#[inline]Enables cross-unit inlining
dyn compatibilityNo generic methods, no Self return
Trade-offSpeed vs binary size

Key takeaways:

  • Monomorphization duplicates code for speed. Each concrete type produces a separate instance, and the direct call allows inlining. The cost is binary size and compilation time .
  • Dynamic dispatch shares code for size. The trait object is a fat pointer with a vtable, and the call is indirect. The cost is the lost inlining and the indirect branch .
  • The vtable is the runtime mechanism. It contains the destructor, size, alignment, and method pointers for the concrete type. The call sequence loads the vtable pointer, then the method pointer, then calls it .
  • Codegen unit partitioning balances compilation time and optimization. The stable/volatile split means a new generic reference invalidates only the instance code, not the non-generic code in the same module .
  • #[inline] is the cross-unit inlining signal. The partitioner distributes only marked functions to other codegen units. Without it, a small function in another crate cannot be inlined .
  • dyn compatibility is the boundary. A trait with generic methods or Self returns cannot be a trait object, because the vtable cannot represent an unbounded set of monomorphizations .
  • The choice is empirical. Profile the hot path. If the indirect call is not visible in the flamegraph, the binary size savings of dyn are the better trade. If the call is in a tight loop and inlining would help, monomorphization is the answer.

Remember: Monomorphization and dynamic dispatch are two code generation strategies for the same problem: calling a method on a value whose type is not fully known at the call site. Monomorphization resolves the type at compile time and generates a specialized copy. Dynamic dispatch defers the resolution to runtime and generates a vtable lookup. Neither is universally correct. The programmer chooses by writing generics or dyn, and the compiler generates what the choice implies.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!