Performance of Generics (Monomorphization)

Understand how Rust achieves zero-cost generics through monomorphization, its impact on runtime performance, compile time, and binary size, and the trade-offs compared to dynamic dispatch

Generics let you write a function or data structure once and use it with many types. In some languages, this flexibility adds a performance penalty—every call might look up a method in a table or box values. Rust takes a different path. When you write a generic function, the compiler ultimately generates a separate, specialized version for each concrete type you actually use. That process is called monomorphization, and it is the engine behind Rust's promise of "zero-cost abstractions."

Zero‑cost does not mean free. It means the abstraction adds no runtime overhead compared to hand‑written code. The cost is paid at compile time and in binary size. Understanding what monomorphization does, how the compiler carries it out, and when the trade‑offs become real is essential for writing performant Rust that stays maintainable.

What Monomorphization Actually Means

"Monomorphization" is a mouthful for a straightforward idea: take a single generic definition (a "polymorphic" function) and stamp out a concrete, non‑generic copy for each unique set of type arguments. The compiler replaces every T with the actual type, producing ordinary machine code that works directly with that type.

Consider this tiny generic function:

fn identity<T>(x: T) -> T {
    x
}

If you call identity(5_i32) and identity("hello"), the compiler generates two distinct functions—something like identity_i32 and identity_str. After monomorphization, your program contains no T at all. Each copy is a separate machine‑code routine that moves an i32 or a string slice, with no dispatch logic, no type checks, and no pointer indirection.

The opposite approach is dynamic dispatch, where the generic code lives once and delegates operations through function pointers stored in a vtable. Java generics (with type erasure) and Rust's dyn Trait both use this model. Monomorphization eliminates that indirection.

The Rust compiler performs monomorphization for all generic items: functions, structs, enums, and methods. Even Option<u32> and Option<String> become completely separate types with their own size, alignment, and method implementations.

The collector step:

The compiler first runs a monomorphization collector that walks every item reachable from main and records which concrete types are needed. Only the combinations actually used in your crate end up in the final binary.

How the Compiler Performs Monomorphization

The monomorphization collector runs after type checking and before code generation. It identifies all generic items that need concrete instances. For each generic function foo<T> called with u32 and String, two copies are added to the list of items to codegen.

Once collected, the compiler generates MIR (Mid‑level Intermediate Representation) with all generics replaced by concrete types. Later, LLVM translates that into machine code. Because every instance is fully known, LLVM can optimize each one aggressively: inlining, dead‑code elimination, constant folding, and more.

The process also affects codegen unit partitioning. A generic function instantiated with many different types across multiple modules may end up in a single codegen unit to avoid duplicate work, but this can increase the time a single codegen unit takes to compile.

The key thing to remember is that monomorphization happens entirely at compile time. The runtime never sees a generic; it only sees the final specialized functions.

Why Monomorphization Wins at Runtime

Static dispatch, made possible by monomorphization, gives the compiler full knowledge of the concrete types and function bodies at every call site. This enables optimizations that dynamic dispatch cannot:

  • The compiler knows exactly which function is called; it can inline it completely.
  • No vtable pointer chasing: method calls are direct jumps.
  • Type‑specific operations (e.g., memcpy for a Copy type, or integer comparisons) are generated without runtime type checks.
  • Memory layout is known precisely; stack allocations are exact.

A simple example:

fn double<T: std::ops::Mul<Output = T> + Copy>(x: T) -> T {
    x * x
}
fn main() {
    let a = double(5_i32);
    let b = double(3.14_f64);
}

After monomorphization and inlining, main can become something like:

mov eax, 5
imul eax, eax
movsd xmm0, 3.14
mulsd xmm0, xmm0

There are no calls to double and no generic bookkeeping. The runtime cost is identical to writing the multiplications by hand.

True zero‑cost runtime:

If your generic function compiles to the same assembly you would write manually for each type, monomorphization is working as intended. You are not paying any dispatch tax.

The Compile‑Time and Binary‑Size Price

No abstraction comes entirely for free. Monomorphization moves the cost from runtime to the build process. Each distinct instantiation of a generic item produces an independent copy of the code. If a large generic function is used with ten different types, the binary contains ten near‑identical versions.

This duplication has two effects:

  1. Longer compile times. The compiler must generate and optimize each specialized copy. LLVM does a lot of work per function, and that work scales with the number of instantiations.
  2. Larger binary size. Every extra copy takes up space in the executable. While the linker may deduplicate identical machine code (thanks to identical code folding), Rust's monomorphized functions are not always identical at the machine‑code level because they use different concrete types.

A generic HashMap<K, V> used with 20 distinct (K, V) pairs will produce 20 separate implementations of every method. That can add megabytes quickly. In some projects, excessive monomorphization has turned a 2 MB binary into a 37 MB one.

Binary bloat is real:

A single widely‑used generic utility can pull in dozens of monomorphized copies. If you are building a CLI tool or a system where binary size matters, audit your generic instantiations and consider whether a trait object can replace some of them.

Compile time grows with variety:

Each new concrete type used with a large generic library (like serde or diesel) adds more work for the compiler. If incremental builds start feeling slow, look at how many distinct types you are feeding into your generics.

Monomorphization and Trait Bounds

Adding trait bounds to a generic parameter does not change the fact that monomorphization creates a separate copy per type. Bounds guarantee that the type has certain capabilities, but the compiler still stamps out a version for each concrete type that satisfies those bounds. The bound just tells the compiler what to check during type checking and what methods to call inside the body.

For instance:

fn say_hello<T: std::fmt::Display>(item: T) {
    println!("Hello, {item}");
}

say_hello("world") and say_hello(42_i32) produce two copies, each with direct calls to the corresponding Display::fmt implementation. The bound only ensures that the .to_string() method exists; the monomorphization still does its work.

Static Dispatch vs Dynamic Dispatch: A Direct Comparison

When you need runtime polymorphism—storing values of different types in the same collection or calling a method without knowing the concrete type at compile time—Rust offers dyn Trait. This uses a vtable and dynamic dispatch. Understanding the trade‑off between the static (generics) path and the dynamic (dyn) path helps you choose the right tool for the performance you need.

Consider a function that reports the length of something:

// Static dispatch via generics
fn report_static(item: &impl std::fmt::Display) {
    println!("{}", item);
}
// Dynamic dispatch via trait objects
fn report_dynamic(item: &dyn std::fmt::Display) {
    println!("{}", item);
}

When you call report_static(&10_i32) and report_static(&"text"), two specialized versions are created, each with a direct call to the respective Display::fmt implementation. Inlining often eliminates the function call entirely.

With report_dynamic, only one copy of the function exists. It receives a fat pointer: a pointer to the data and a pointer to a vtable. Every call to Display::fmt goes through that vtable, which prevents inlining and adds a level of indirection.

The performance difference is negligible for a simple println!, but inside a tight loop, static dispatch can be significantly faster because the compiler can see through the call and optimize across it.

Neither approach is universally better:

  • Use generics (static dispatch) when you know the concrete types at compile time and you want the fastest possible code.
  • Use trait objects when you need to store a heterogeneous collection or when compile‑time and binary size matter more than the last bit of performance.

Blurring the line:

The optimizer can sometimes devirtualize dynamic dispatch if it can prove the concrete type at compile time. However, you should not rely on that for critical paths. Assume dyn Trait calls involve a vtable unless proven otherwise.

Common Misconceptions

  • "Generics are exactly like C++ templates." They share the monomorphization idea, but Rust generics are type‑checked before instantiation, and C++ templates are not. This avoids the famous error messages deep inside template expansions.
  • "Zero‑cost means free." Zero‑cost means no overhead at runtime compared to hand‑written code. It does not mean that switching from a concrete type to a generic suddenly reduces costs; the cost is shifted to compilation and binary size.
  • "Monomorphization happens only for functions." It happens for structs, enums, and methods too. Option<i32> and Option<String> are distinct types with separate implementations.
  • "Every generic call creates a new copy." The compiler creates a copy only for each unique concrete type, not for each call site. Calling identity(5) ten times generates one identity_i32 function.
  • "The runtime is the same regardless of the number of generic instantiations." In terms of CPU cycles per call, yes. But a larger binary can cause more instruction cache misses and increase program load time.

Working with Monomorphization in Real Projects

To keep compile times and binary size manageable without sacrificing performance, you can apply a few strategies:

  • Limit the number of distinct types used with a heavy generic library. If you need a HashMap<String, i32> in many places, that's just one instantiation—not a problem.
  • Factor large generic methods into smaller, non‑generic helper functions that operate on concrete types or use dynamic dispatch internally. This reduces the duplicated code surface.
  • Use trait objects for non‑critical paths. For example, a function that handles configuration items of many types might accept &dyn ConfigValue instead of being generic.
  • Monitor your binary size. Tools like cargo bloat can show which monomorphized functions are taking up the most space. Sometimes a small refactor can merge several instantiations.
  • Profile compile times. cargo build --timings can reveal whether generic instantiation is the bottleneck. If it is, consider how many distinct types your generics support.

Stay intentional:

Monomorphization is a powerful tool. Let performance requirements guide your choices. In a CLI utility, a few extra kilobytes from generics are invisible; in a hot loop, the inlining from static dispatch may be essential.

Summary

Monomorphization transforms generic Rust code into a set of concrete, type‑specialized functions at compile time. It delivers the runtime speed of hand‑written, type‑specific code while keeping the source flexible and safe. The price is longer compile times and larger binaries.

Understanding this trade‑off lets you write generics confidently: you can use them for both performance and clarity, and switch to trait objects when runtime polymorphism or binary size becomes a concern.