Skip to content
rusthacks.dev
Performance

Cutting Monomorphization Bloat in Rust With Cargo Llvm Lines

Generic code compiles once per type, and that can bloat a binary and slow builds. Find the biggest instantiations with cargo llvm-lines and cut them with inner functions and dyn.

3 min read
Summarize in AI
Flat illustration for the post “Cutting Monomorphization Bloat in Rust With Cargo Llvm Lines”, composed around cargo.

When you write fn save<P: AsRef<Path>>(path: P) and call it with a String, a &str and a PathBuf, the compiler generates three separate versions of save, including its whole body. For small functions that is exactly what you want. For large ones it is a quiet source of long builds and big binaries. Everything here applies to Rust 1.90; measure your own crate, since each result below depends on it.

See it

Shell
cargo install cargo-llvm-lines
cargo llvm-lines --release | head -20
Text
  Lines          Copies        Function name
  -----          ------        -------------
  41390 (100%)   1234 (100%)   (TOTAL)
   2842 (6.9%)     14 (1.1%)   mycrate::load::<impl>::read_all
   1530 (3.7%)      9 (0.7%)   core::ptr::drop_in_place

(The numbers are an illustrative layout, not a measurement.) Read it from the top: a function with a high line count and several copies is where duplication costs the most.

The inner function trick

rust
use std::{fs, io, path::Path};

pub fn read_config<P: AsRef<Path>>(path: P) -> io::Result<String> {
    fn inner(path: &Path) -> io::Result<String> {
        let text = fs::read_to_string(path)?;
        // ... a long body: validation, trimming, parsing ...
        Ok(text.trim().to_string())
    }
    inner(path.as_ref())
}

The outer generic function is one line. It converts the argument to &Path and calls inner, which is not generic and compiles once. Callers keep the convenient signature (read_config("a.toml"), read_config(PathBuf::from(..))), and the compiler emits one copy of the large body and N copies of the tiny wrapper. The standard library applies this technique in std::fs functions.

Take the concrete type internally

rust
pub fn greet(name: impl AsRef<str>) -> String {
    greet_str(name.as_ref())
}

fn greet_str(name: &str) -> String {
    format!("hello, {name}")
}

Same idea with a named helper. For internal functions that only ever get a &str, do not make them generic at all.

dyn Trait for cold code

rust
fn log_error(err: &dyn std::error::Error) {
    eprintln!("error: {err}");
    let mut source = err.source();
    while let Some(s) = source {
        eprintln!("  caused by: {s}");
        source = s.source();
    }
}

An error reporter runs rarely, so the indirect call costs nothing noticeable, and it compiles once instead of once per error type. Use &dyn at boundaries where speed is irrelevant and generics where a loop runs millions of times (static versus dynamic dispatch, Box dyn Trait).

Other sources of duplication

  • Closures: each closure is its own type, so a function taking F: Fn() is copied for every distinct closure. Take &dyn Fn() or a function pointer for cold callbacks.
  • Generic structs with large methods: put the non-generic work in free functions.
  • Result<T, E> combinations with many ? conversions each produce glue code.
  • Heavy derive macros on many types, which show up in compile time profiling.

Verify

After a change, rerun cargo llvm-lines, compare the total, and compare binary size and clean build time. If the numbers barely move, revert; the change made the code harder to read for nothing. Link-time optimization may merge identical copies on its own (LTO), so measure with your release settings. Generics fundamentals are in generics and monomorphization.

Also inGenerics
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.