arthrop0d

Brief articles on varied topics.

Rust Monomorphization and Static Dispatch

compilersrustperformance

Rust monomorphization is the compiler’s process of turning a generic function into separate machine-code versions for each concrete type used with it. Those type-specific versions let Rust resolve many calls at compile time, a technique called static dispatch.

The problem generics create

A generic function is written once but must work with multiple types:

fn larger<T: Ord>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}

fn main() {
    let number = larger(3, 5);
    let word = larger("rust", "go");
}

Here, T is a type parameter: a placeholder for a type selected by the caller. The Ord bound says that the selected type must support ordering, so the function can use >=.

The machine cannot execute a placeholder type. It needs to know such details as how values are represented, how large they are, and which implementation of an operation to call. A compiler therefore needs a strategy for turning this generic source into executable code.

One strategy is to erase the type information and use a common runtime representation. Calls then need extra information at runtime, such as a table of function pointers. This is useful when the exact type can vary while the program is running, but it adds indirection and limits some optimizations.

Rust usually takes a different route for ordinary generic code: it specializes the code before the program runs.

How monomorphization works

The compiler first type-checks the generic definition and determines the concrete types at each call site. In the example, it finds two uses:

The ::<...> notation is the explicit form of choosing a generic type; Rust inferred these types in the example.

Conceptually, monomorphization produces something like this:

fn larger_i32(a: i32, b: i32) -> i32 {
    if a >= b { a } else { b }
}

fn larger_str(a: &str, b: &str) -> &str {
    if a >= b { a } else { b }
}

These are not literally the source functions Rust asks you to write. They represent the important result: each use has a version whose operations are selected for one concrete type. The compiler then lowers those versions through Rust’s intermediate representations and eventually emits machine code for them.

If a generic function is never used, the compiler normally has no reason to emit a machine-code instance for it. If it is used with ten substantially different type combinations, it may generate ten instances. A function with two type parameters can multiply this effect across combinations of both parameters.

The actual compiler and linker can remove unused instances, optimize them, or sometimes merge identical generated code. Therefore, the final binary is not guaranteed to contain a perfectly separate copy for every source-level use. But the model of “one specialized instance per needed type combination” is the right way to understand the mechanism.

Why this is static dispatch

Dispatch is the act of selecting which function implementation a call should execute. With static dispatch, that selection is made during compilation rather than by inspecting a value at runtime.

For a generic call such as larger(3, 5), the compiler knows that T is i32. It can emit a direct call to the i32 instance—or inline the function into its caller if optimization decides that is worthwhile. Inlining means replacing a call with the called function’s body; it is a separate optimization, not a requirement for monomorphization.

This has several benefits:

A trait is Rust’s description of behavior that a type can provide. The Ord bound in the example is what allows the generic function to use ordering without knowing the type in advance.

Rust also supports dynamic dispatch, usually through a trait object such as &dyn Ord when the trait’s object-safety rules permit that use. A trait object carries a value together with runtime information describing its implementation. Calling a trait method typically loads a function pointer from a vtable, a table of implementation pointers. That avoids generating a separate generic version for every concrete type, but adds an indirect call and gives the optimizer less concrete information.

The choice is therefore not simply “generics versus no generics.” Ordinary generic functions generally use static dispatch; trait objects are a way to request runtime-selected behavior when that flexibility matters.

The costs of generating specialized code

Monomorphization trades runtime work for work performed during compilation and for potentially larger binaries.

Compile time can increase because the compiler must analyze, optimize, and generate machine code for each needed instance. Large generic functions used across many types are particularly expensive. In a workspace, changing a generic definition can also require rebuilding many downstream instances.

Code size can increase because specialized instances may contain similar copies of the same logic. A larger executable can increase installation size, instruction-cache pressure, and the amount of code that must be loaded. The linker and optimizer often reduce this duplication, so the cost depends heavily on the code and the selected optimization settings.

These costs are most noticeable when a generic function has a large body, is instantiated with many types, or appears in heavily reused library code. Small generic functions are often a good trade: they avoid runtime dispatch and may disappear entirely through inlining.

When binary size or build time matters more than the cheapest possible call, a design can move a boundary to dynamic dispatch or place type-independent work in a non-generic function. The goal is not to avoid generics universally, but to keep large, repeatedly specialized bodies from multiplying unnecessarily.

When a linker error, profiling symbol, or build report contains long type-expanded names, it may be showing a monomorphized instance. That output is the compiler’s evidence that Rust has turned one generic definition into concrete code for the types your program actually uses.

← All articles