Rust Performance Engineering

Monomorphization Costs

Monomorphization is the compile-time process by which rustc turns generic code (fn process<T: AsRef<Path>>(path: T)) into specialized, concrete machine-code functions for every unique type T used at call sites (process::<&Path>, process::<PathBuf>, process::<&str>, process::<String>). While monomorphization eliminates virtual dispatch overhead at runtime, excessive monomorphization of large generic functions causes code bloat, slower compile times, and increased L1 instruction cache pressure.

Without Polymorphic Thunk (Naive 200-line Generic Function):
  `open_config::<&str>("a.toml")`      ---> 200 lines of LLVM IR duplicated (#1)
  `open_config::<String>(s)`           ---> 200 lines of LLVM IR duplicated (#2)
  `open_config::<&Path>(p)`            ---> 200 lines of LLVM IR duplicated (#3)
  `open_config::<PathBuf>(pb)`         ---> 200 lines of LLVM IR duplicated (#4)
  (800 lines of LLVM IR compiled & linked for the exact same file logic!)

With Non-Generic Inner Function Pattern (1-line Generic Wrapper):
  `open_config::<T: AsRef<Path>>(p: T)` ---> 1-line inline conversion: `inner(p.as_ref())`
                                                        |
                                                        v
                                      `fn inner(path: &Path)` (Compiled ONCE!)

Trade-Offs of Monomorphization

Aspect Benefit of Monomorphization Cost When Overused on Large Functions
Runtime Dispatch Direct static call (call instruction) instead of indirect vtable jump. None for small functions.
Inlining & SIMD Concrete types (u32, f64) allow LLVM to inline and vectorize inner loops. Duplicating a 500-line parser across 12 reader types bloats binary .text size and evicts hot code from the CPU's L1 instruction cache (L1i).
Compilation Time Type errors caught before codegen. LLVM must optimize and emit machine code for $N$ copies of the function (often the #1 cause of slow Rust builds!).

The Non-Generic Inner Function Pattern ("Monomorphization Thinning")

The standard library uses a simple, zero-cost technique—often called monomorphization thinning or the non-generic inner function pattern—on almost every function that accepts AsRef<Path>, AsRef<[u8]>, AsRef<str>, or Into<String> (including std::fs::read, std::fs::write, and File::open):

  1. Keep the public generic function as a 1-line #[inline] wrapper that converts T into the concrete borrowed type (&Path, &[u8], &str) or owned type (String).
  2. Delegate all the real work to a private, non-generic inner function that is compiled only once!
use std::path::Path;

#[derive(Debug, PartialEq, Eq)]
pub struct PathReport {
    pub has_extension: bool,
    pub depth: usize,
    pub normalized: String,
}

/// Public generic API: accepts `&str`, `String`, `&Path`, `PathBuf`, `OsString`, etc.
/// Because its body is just `analyze_path_inner(path.as_ref())` marked `#[inline]`,
/// callers get full generic ergonomics with ZERO duplicated machine code!
#[inline]
pub fn analyze_path<P: AsRef<Path>>(path: P) -> PathReport {
    analyze_path_inner(path.as_ref())
}

// Private non-generic implementation: compiled and optimized by LLVM exactly ONCE!
fn analyze_path_inner(path: &Path) -> PathReport {
    let has_extension = path.extension().is_some();
    let depth = path.components().count();
    let normalized = path
        .components()
        .map(|c| c.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/");

    PathReport {
        has_extension,
        depth,
        normalized,
    }
}

fn main() {
    // Calling `analyze_path` with 3 different types (`&str`, `String`, `&Path`)
    // all inline straight to the single `analyze_path_inner(&Path)` machine function!
    let r1 = analyze_path("src/bin/server.rs");
    let r2 = analyze_path(String::from("Cargo.toml"));
    let r3 = analyze_path(Path::new("tests/integration/main.rs"));

    println!("r1: depth={}, ext={}", r1.depth, r1.has_extension);
    println!("r2: depth={}, ext={}", r2.depth, r2.has_extension);
    println!("r3: depth={}, normalized={}", r3.depth, r3.normalized);
}
r1: depth=3, ext=true
r2: depth=1, ext=true
r3: depth=3, normalized=tests/integration/main.rs

Measuring Monomorphization Bloat with cargo-llvm-lines

To find which generic functions in your crate generate the most duplicated LLVM IR lines and take the longest to compile, run cargo llvm-lines (cargo install cargo-llvm-lines --locked):

cargo llvm-lines | head -n 15
  Lines                Copies              Function name
  -----                ------              -------------
  84120                84                  (TOTAL)
  14200 (16.9%)        18 (21.4%)          my_crate::parser::parse_stream
   3120 (3.7%)          1 (1.2%)           my_crate::analyze_path_inner