Monomorphization
One generic body becomes a separate compiled function per type it is used with. The type is then concrete, which is what makes inlining, known layouts and devirtualization possible — and the bill arrives as code size and compile time.
Why does my Rust binary grow every time I add a generic call, and what do I get for it?
Before: one generic body plus a set of instantiation sites. After: no generic body at all, and one ordinary non-generic function per distinct type argument set. That is the point of the representation change — the rest of the pipeline can treat each instantiation as concrete code over concrete types, with known sizes, known layouts and direct calls.
Monomorphization is only possible where the full set of required instantiations is statically known to whichever compilation unit does the work. A separately compiled instantiation must therefore either be emitted in the caller's object file and deduplicated by the linker, or the generic body must be shipped in an interface artifact the caller can instantiate from. This is why C++ templates live in headers and Rust generics live in crate metadata: the alternative — a generic body compiled once into a library — cannot be instantiated by a caller that has never seen it.
Key points
- Monomorphization replaces one generic body with one concrete function per distinct type argument set; the generic body is not present in the output.
- The payoff is not the specialised body but what a known type enables: inline layout, direct calls, inlining, and every optimization that can then see through the fused code.
- It is only available where the instantiation set is statically known, which is why generic code must be shipped as source or as instantiable metadata rather than as a compiled library.
- Polymorphic recursion generates an unbounded instantiation family and must be rejected by a monomorphizing compiler.
- The costs are code size, compile time proportional to instantiation count, instruction cache pressure and duplicated debug info.
- Growth is multiplicative rather than additive once inlining is applied on top of instantiation.
- Go compiles one body per GC shape with a dictionary parameter, bounding code growth and giving up much of the specialisation benefit.
One body becomes many
Write fn max<T: Ord>(a: T, b: T) -> T and call it with i32 and with String, and the compiler emits two functions. Each is an ordinary, non-generic function whose parameter types are concrete. The generic definition is a template that produced them; it is not itself present in the binary.
The immediate effect is that within each instantiation nothing is abstract any more. T is i32, so its size is four bytes, so it lives in a register, so the comparison is a single instruction. In the String instantiation T is a three-word struct, the comparison is a call to a string comparison routine, and the code is entirely different. Neither body could have been produced without committing to the type.
Contrast with the erased strategy from [[type-erasure]], where one shared body works over a boxed representation. That body cannot know a size, cannot store values inline, and must call through a pointer for every operation on T. Monomorphization removes all three limitations by removing the genericity.
fn max<T: Ord>(a: T, b: T) -> T {
if a > b { a } else { b }
}
let x = max(3i32, 7i32);
let s = max(a_string, b_string);fn max_i32(a: i32, b: i32) -> i32 {
if a > b { a } else { b } // becomes a compare and a cmov
}
fn max_String(a: String, b: String) -> String {
if a > b { a } else { b } // calls String::cmp; moves three-word values
}
let x = max_i32(3, 7);
let s = max_String(a_string, b_string);Only where every instantiation required by the program is known to some compilation unit that can emit it — which means either the generic body is available at the call site (a header, or crate metadata), or the callee was compiled with knowledge of every argument type it would receive. The transformation preserves behavior because substitution of a concrete type for a type parameter is meaning-preserving in a parametric type system: the body was type-checked against the bound, and the argument satisfies the bound.
The instantiation set is not statically bounded. Polymorphic recursion — a generic function that calls itself at a strictly larger type, such as f<T> calling f<(T, T)> — generates an infinite family, so a monomorphizing compiler must reject it (Rust does, with a recursion-limit error; C++ hits template instantiation depth). It is also unavailable when the type is chosen at runtime: a trait object, a dynamically loaded plugin type, or a value deserialized into a type not known at build time cannot be monomorphized and must go through dynamic dispatch instead.
What it enables downstream
The reason monomorphization matters is not the specialised body itself; it is what becomes possible once the type is concrete. Three things in particular, and they compound.
First, layout is known, so values can be stored inline rather than behind a pointer. A Vec<Point> is a contiguous array of points, not an array of pointers to points. That is a memory-locality difference of the same kind [[type-erasure]] describes for boxed collections, and for large data it dominates everything else in this lesson.
Second, calls become direct, so they can be inlined. A generic function calling T::cmp has, after substitution, a call to a specific known function — which the inliner may then bring into the caller under its own budget rules. [[inlining]] is where the budget lives; the point here is that monomorphization is the step that turns an unknown target into a known one.
Third, once a call is inlined, the surrounding optimizations can see through it, which is where most of the actual gain comes from. Constant folding, dead code elimination and bounds-check elimination all work on the fused body in a way they could not work across an opaque generic call. An iterator chain in Rust compiles to a loop with no intermediate structures precisely because every adaptor was monomorphized, then inlined, then optimized as one unit — none of which is possible if any link in that chain is dynamically dispatched.
The same reasoning explains [[devirtualization]] from the other direction: where monomorphization is unavailable, a compiler tries to recover a known target by proving which implementation is used, and where it succeeds it gets the same downstream benefits.
What it costs
&dyn Trait — is a recognised Rust idiom for bounding code growth, and std::fs::File::open uses it for exactly this reason. It is an implementation technique available in any monomorphizing language, and the equivalent in C++ is a template shim over a type-erased implementation. The trade is one extra indirect call in exchange for one body instead of many.The bill is code size, and it is not subtle. Every instantiation is a separate body, so a generic function used with twenty types produces twenty bodies. Because the bodies then get inlined into their callers, the growth is multiplicative rather than additive: a generic function inlined at fifty call sites across twenty instantiations contributes a great deal more than twenty function bodies.
Compile time follows the same curve, and worse, because the work is per instantiation: type-check once, then optimize and codegen once per type. This is the dominant reason C++ builds using heavy template libraries are slow, and a substantial part of why Rust builds are slower than Go builds. It is also why both languages have a deduplication story — C++ marks template instantiations as COMDAT sections so the linker can fold identical copies emitted in different translation units, and Rust relies on the same LLVM and linker machinery plus per-crate instantiation caching.
The second-order cost is instruction cache pressure, and it is the one that turns a code-size number into a performance regression. Twenty copies of a function that fitted comfortably in cache as one copy do not fit as twenty. This is precisely why "monomorphization is faster" is not a statement anyone should make unconditionally: it produces better code per call site and more total code, and which effect wins depends on how hot each site is. A cold generic used at forty types is a pure loss.
The third cost is debug information, which is duplicated per instantiation, and binary size in contexts where it is directly constrained — embedded targets, WebAssembly modules shipped over a network. In those settings teams deliberately erase genericity by hand, replacing a generic function with an inner non-generic one that does the work over a trait object, so that only a thin shell is duplicated.
Go's middle route: GC shape stenciling
Go added generics in 1.18 and chose neither erasure nor full monomorphization. The implementation compiles one body per *GC shape* — a class of types that the garbage collector and the calling convention treat identically, so that all pointer-shaped types share a body — and passes a hidden dictionary parameter carrying the per-instantiation information the shared body needs, such as method addresses and type descriptors.
The motivation is Go's overriding commitment to compile speed. Full monomorphization would have made build times a function of instantiation count, which the language has spent fifteen years refusing to accept. Stenciling bounds the number of emitted bodies by the number of distinct shapes rather than the number of distinct types, which for typical Go code is a much smaller number.
The price is specialisation. A shared body over a shape cannot inline a method call that differs per instantiation — it loads the address from the dictionary and calls it. So Go generics generally do not deliver the code-quality benefits Rust generics do, and in some cases a generic Go function is measurably slower than the non-generic copy it replaced. That is an accepted trade in the design, not a bug, and it is the clearest available demonstration that the three strategies in [[type-erasure]] are points on a spectrum rather than a ranking.
| Strategy | Bodies emitted | Compile time | Code quality per site | Layout known? |
|---|---|---|---|---|
| Full monomorphization (Rust, C++)implementation | One per distinct argument set | Grows with instantiation count | Best available — inlining and specialisation apply | Yes |
| GC shape stenciling (Go 1.18+)implementation | One per shape, plus a dictionary | Roughly flat in instantiation count | Limited — per-instantiation calls go through the dictionary | Partly |
| Reified, JIT-specialised (CLR)implementation | One shared for references, one per value type | Deferred to runtime | Good for value types, shared for references | Yes, at runtime |
| Erasure (Java, TypeScript)spec | One, always | Flat | Worst — boxed representation, indirect operations | No |
How it works
The steps, in the order the compiler takes them.
- The frontend type-checks the generic body once against its bounds, so no per-instantiation type errors are possible.
- A collection pass walks the call graph from the roots, recording every distinct type argument set at which each generic item is used.
- For each recorded set the compiler substitutes the arguments into a fresh copy of the body, producing a non-generic item with a mangled name encoding the arguments — see
[[name-mangling]]. - Trait or interface method calls inside the body resolve to concrete implementations during substitution, turning indirect calls into direct ones.
- The instantiations are then handed to the ordinary optimization pipeline as normal functions; inlining, constant propagation and the rest apply with no knowledge that they were generic.
- Identical instantiations emitted in several compilation units are placed in mergeable sections so the linker can fold them, which is what stops the duplication from being as bad as the naive count suggests.
- A recursion limit bounds the substitution process so that polymorphic recursion terminates with a diagnostic rather than running forever.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A release binary doubles in size after a refactor that made a widely used function generic; nothing is slower in a microbenchmark and the deployment artifact no longer fits the target's constraints.
- Compile times climb from thirty seconds to four minutes as a generic utility is used at more types, and the profile shows codegen rather than type checking as the cost.
- A tight loop gets slower after a generic function is used at twenty types: each site is individually better and the instruction cache is thrashing, which no per-call benchmark reveals.
- A compiler reports a recursion-limit error on a generic function that looks obviously terminating to the author, because the recursion is at a larger type on each step.
- A team ships a generic API as a compiled library and downstream users cannot instantiate it at their own types; the fix is to expose the source or the metadata, which is a distribution change rather than a code change.
- A Go generic function replaces two hand-written concrete copies and benchmarks slower than both, because the shared shape body calls through the dictionary where the concrete copies called directly.
When it helps
- Hot generic code used at a small number of types — collections, numeric routines, iterator chains — where specialisation and inlining together remove most of the abstraction cost.
- Anywhere layout matters: storing values inline rather than behind pointers is frequently the largest single effect and is unavailable without a known type.
- Zero-cost abstraction goals generally: the whole approach exists so that a well-factored generic API compiles to what a hand-written concrete version would.
- Enabling later passes — devirtualization, bounds-check elimination, vectorization — that require a concrete type to make progress.
When it hurts
- Cold generic code used at many types, where every instantiation is paid for in size and none of it is paid back in speed.
- Size-constrained targets — embedded firmware, WebAssembly bundles — where binary size is a hard requirement rather than a preference.
- Build-time-sensitive projects, where instantiation count drives codegen work and incremental rebuilds re-do it.
- APIs whose type argument is genuinely chosen at runtime, where dynamic dispatch is not a fallback but the correct design.
What it costs
Every one of these is paid by something.
- Specialisation buys concrete layouts and direct calls, and pays in code size that grows multiplicatively once inlining is applied on top — a generic function at twenty types inlined at fifty sites is a great deal more than twenty bodies.
- Compile-time instantiation buys the best available code quality per site and pays with build time proportional to the number of instantiations, which is the single largest cost in template-heavy C++ and a significant one in Rust.
- Requiring the generic body at the call site buys the ability to instantiate at any type and pays in distribution: generic code cannot be shipped as an opaque compiled artifact, which constrains how libraries are packaged and how ABIs can be stabilised.
- GC shape stenciling buys bounded code growth and flat build times, and pays in specialisation — per-instantiation operations become dictionary-mediated indirect calls that cannot be inlined.
What else you could do
What a different compiler or language does instead, and when that is better.
- Dynamic dispatch through a trait object or interface: one body, a pointer-sized value, a vtable call per operation. Correct when the type is chosen at runtime, when the call is cold, or when binary size is the binding constraint.
- Erasure with boxing (Java, TypeScript): one body always, no code growth at all, and a boxed representation that costs allocation and locality — see
[[type-erasure]]. - GC shape stenciling (Go): one body per layout class plus a dictionary, trading specialisation for bounded growth and fast builds.
- JIT specialisation (CLR, and speculatively in JVM JITs): defer the decision to runtime and specialise only the instantiations that are actually used and hot, which avoids paying for code that never runs at the cost of warmup — see
[[jit-compilation]]. - Manual type erasure at a chosen boundary — a thin generic shell over a non-generic worker — recovers most of the size back by hand, at the cost of one indirect call.
See it for yourself
The flag, dump or tool that shows you this directly.
nm -C target/release/binary | grep maxlists one mangled symbol per instantiation; the count is the direct measurement of what was generated.cargo bloat --release --cratesandcargo llvm-linesattribute generated code to functions and crates, andllvm-linesin particular ranks by instantiation count — the standard tool for finding the generic that is costing you a megabyte.cargo build --timingsshows per-crate codegen time, which is where instantiation cost appears rather than in type checking.- For C++,
-ftime-tracein clang produces a per-template-instantiation timeline that can be opened in a trace viewer, andnmplusc++filtshows the emitted instantiations. - For Go,
go build -gcflags=-mreports inlining decisions, and comparing a generic function against a hand-written concrete copy in a benchmark is the practical way to see what stenciling cost. - Compiler Explorer with two instantiations side by side shows the two emitted bodies directly, which is the fastest way to convince yourself the substitution is real.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Monomorphization makes code faster." It makes each call site better and the program larger. Whether that is a net win depends on how hot the sites are and whether the working set still fits in cache.
- "The compiler generates code for every possible type." It generates code for every type the program actually uses, discovered by walking the call graph from the roots. Unused instantiations cost nothing.
- "Generics are free in Rust." Free at runtime for the calls that get specialised and inlined; not free in binary size, compile time or instruction cache, and those costs are the ones people are surprised by.
- "Go has generics now, so it has the same performance story as Rust." Go compiles one body per GC shape and dispatches through a dictionary, which is a deliberately different point on the spectrum with a deliberately different cost profile.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
When you write one function that works for many types, a monomorphizing compiler makes a separate copy for each type you actually use it with. Inside each copy the type is concrete, so the code can be as good as if you had written it by hand for that type. The cost is that you now have several copies, which makes the binary bigger and the build slower.
practical
If binary size or build time is a problem, find the generics with the most instantiations — cargo llvm-lines ranks them directly — and consider the shell trick: keep a small generic wrapper for ergonomics and have it call a non-generic function that does the work over a dynamically dispatched argument. Only the shell is duplicated. Conversely, if a hot loop is slower than expected, check whether the call it makes was actually specialised and inlined, because a generic function too large for the inliner's budget gives you the code growth without the speed.
advanced
The interesting question is not monomorphization versus dynamic dispatch but where in the pipeline the binding happens, because that decides which optimizations are still available afterwards. Bind at compile time and every later pass sees concrete types, so inlining, layout specialisation, bounds-check elimination and vectorization all become possible — and the binary carries every instantiation whether it runs or not. Bind at runtime, as a JIT does, and only the instantiations that are actually reached get compiled, so the code that exists is code that ran — at the cost of warmup and of a compiler in the process. Bind never, as erasure does, and the code is small and the representation is boxed. Go's stenciling is a fourth position: bind the layout at compile time and the behaviour at runtime through a dictionary. Every language's generics story is a choice of one of these four, and the arguments for each are about deployment constraints at least as much as about speed.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
dyn Trait. A future implementation could choose a different strategy for some generics without changing the language.If you were asked this in an interview
- What does monomorphization make possible that an erased generic cannot do, and why?
- Your release binary grew forty percent after a refactor to generics. How would you find out where it went, and what would you do about it?
- Why can a monomorphizing compiler not compile a generic function once into a library for others to use at their own types?