Skip to content
rusthacks.dev

Link Time Optimization in Rust and Its Build Time Cost

LTO lets the compiler optimize across crates and often shrinks and speeds a binary, at the cost of build time. See thin, fat and off, and how to measure the trade.

6 min read
Summarize in AI
Flat illustration for the post “Link Time Optimization in Rust and Its Build Time Cost”, composed around perf.

Link time optimisation is the setting people turn on because a benchmark post told them to, and then forget, until their CI build takes three times longer. It does one specific thing: it lets the optimiser see the whole program instead of one crate at a time. Whether that is worth your build minutes depends on what you were trying to gain, and in my measurement the answer was "size, yes; speed, no". This post explains what LTO changes, measures the three settings, shows a case where it did nothing at all and says why, and gives a rule for choosing.

All numbers come from Cargo 1.99 and rustc 1.99 with edition 2024, on a 10-core Apple M1 Max. The size and build-time program uses serde_json 1 and regex 1; the timing program is a small workspace listed below.

What LTO actually joins

Rust compiles each crate separately, then links the results. When your crate calls a function in a dependency, the optimiser compiling your crate usually sees only the function's signature, so it cannot inline the body or drop what you never call. LTO postpones the last stage of optimisation until link time, when every crate's code is available, so the optimiser can inline across crate boundaries and delete unreachable functions from the dependencies.

Cargo exposes it as lto in a profile:

ValueMeaningCost
false (default)"thin local": optimises across the codegen units of one crate onlynone
"thin"whole program, with parallel work split across unitsmoderate
"fat" or truewhole program merged into one module and optimised togetherhighest, mostly single threaded
"off"no LTO at all, not even the local kindnone; rarely useful

The measurement: size and build time

Same program, same machine, one setting changed per row, default 16 codegen units unless stated. Clean build time includes compiling the dependencies, and the binary was not stripped:

Text
lto setting            clean build   binary size
off                    4.0 s         2,251,296 B
false (default)        4.3 s         2,234,928 B
thin                   5.5 s         2,237,600 B
fat                    11.2 s        1,766,432 B
thin + codegen-units=1 8.1 s         1,801,136 B
fat + codegen-units=16 11.1 s        1,766,432 B

Read the table in two passes. First, thin with the default 16 codegen units gave 2,237,600 bytes: no smaller than doing nothing, for an extra 1.2 seconds. Thin LTO needs units to be worth anything, and here the parallelism that makes it cheap also splits the program into pieces too small to benefit. Second, fat shaved 21 percent off the binary and took 11.2 seconds, 2.6 times the default. The last row shows why people are confused: fat ignores codegen-units, because it merges everything into one module first, so 16 and 1 give the same size and the same time. The cheap way to get nearly the same size is thin together with codegen-units = 1: 1,801,136 bytes in 8.1 s. That is 2 percent bigger than fat and a quarter faster to build.

A case where LTO changed nothing

The usual claim is that LTO makes code faster by inlining across crates. To test it, build a library whose function sits in another crate from its only caller, in a loop that calls it two billion times. The library:

mathlib/src/lib.rsrust
// No #[inline]: without LTO, a caller in another crate cannot see this body.
pub fn mix(state: u64, x: u64) -> u64 {
    (state ^ x).wrapping_mul(0x9E37_79B9_7F4A_7C15).rotate_left(23)
}
app/src/main.rsrust
use std::time::Instant;

fn main() {
    let start = Instant::now();
    let mut state = 1u64;
    for i in 0..2_000_000_000u64 {
        state = mathlib::mix(state, i);
    }
    println!("state {state:#018x} in {:?}", start.elapsed());
}
Cargo.tomlTOML
[workspace]
members = ["mathlib", "app"]
resolver = "3"

Run it once per LTO setting with CARGO_PROFILE_RELEASE_LTO=off|thin|fat cargo build --release:

Text
lto=off    state 0x896d9c211c2bc720 in 3.238915625s
lto=thin   state 0x896d9c211c2bc720 in 3.267602416s
lto=fat    state 0x896d9c211c2bc720 in 3.207519792s

Identical to within noise, and the final state is the same in all three, which confirms every build computed the same thing. The reason is that rustc has marked small, simple functions as cross-crate-inlinable automatically since Rust 1.75, so mix, a one-liner, was inlined into the loop without any LTO. The function would have needed an explicit #[inline] in older compilers, which is where much of the old folklore about LTO speedups comes from. I also made mix larger (four rounds with a branch) to defeat the automatic inlining, and the three timings still agreed to within one percent at about 51 seconds each: the loop was bound by the dependency chain through state, not by call overhead. A real speedup needs a hot call to a function that is both too big for automatic inlining and cheap enough that the call matters, and many programs do not have one.

What it cost when it fails to fit

Fat LTO holds the entire program's intermediate code in memory and optimises it largely on one thread. Build time and peak memory grow with program size, which means the 7 extra seconds here become minutes on a service with hundreds of dependencies, and a CI runner with 4 GB can be killed mid-link. If a fat build is the thing that makes your pipeline slow, measure it before deciding it is worth it: cargo build --release --timings writes an HTML report showing which units dominate. A mistyped value fails fast with a clear message:

Text
error: incorrect value `full` for codegen option `lto` - either a boolean (`yes`, `no`, `on`, `off`, etc), `thin`, `fat`, or omitted was expected

Many people write "full" from memory; the word is "fat", or true.

One more cost explains why fat LTO feels slow to iterate on. The release profile does not use incremental compilation by default, and the link-time pass works on the merged whole program, so touching one line redoes the expensive part of the build rather than a small slice of it. That is the practical reason to keep fat LTO in a separate dist profile, built only when you publish, and to leave the profile you use every day alone.

A rule for choosing

For a binary you ship once a week, use lto = "fat" with codegen-units = 1 in a dedicated dist profile and take the 20 percent size reduction. For the profile you build on every push, use the default or thin with codegen-units = 1 if you need the size and can spare a few seconds. For libraries, do nothing: a library's [profile] is ignored when someone depends on it, and LTO is the application's decision. If speed is the goal, profile first (perf, cargo flamegraph) and look for a hot call that crosses a crate boundary, because blanket LTO will not find it for you.

Cargo.tomlTOML
[profile.dist]
inherits = "release"
lto = "fat"
codegen-units = 1
strip = true

When LTO does not apply

It has no effect on a single-crate program with no dependencies beyond std, since the standard library ships precompiled. It complicates builds that mix Rust with C through -C linker-plugin-lto, which needs matching LLVM versions. And it does not help debug builds, where optimisation is off. Continue to codegen units and opt level for the sibling settings, to release profile settings for the combined recipe, and to the profiles reference for the precise semantics.

Taggedlto
Share

Keep reading

Flat illustration for the post “Stale While Revalidate Caching in Rust Services Explained”, composed around perf.
Performance

Stale While Revalidate Caching in Rust Services Explained

Serve the cached value immediately, even if slightly old, and refresh it in the background. Learn the idea, the HTTP Cache-Control directives and an in-process implementation with a freshness window and a grace window.

4 min read

Get new posts by email

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

Unsubscribe in one click. No spam, ever.