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.

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
cargo install cargo-llvm-lines
cargo llvm-lines --release | head -20 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
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
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
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.


