Skip to content
rusthacks.dev

Codegen Units and Opt Level in Rust and When to Change Them

codegen-units splits a crate into parallel pieces and opt-level picks how hard LLVM works. See the defaults, the trade-offs and the settings for speed or size.

5 min read
Summarize in AI
Flat illustration for the post “Codegen Units and Opt Level in Rust and When to Change Them”, composed around perf.

Two profile settings sit at the centre of nearly every Rust build-speed argument: opt-level, how hard LLVM optimises, and codegen-units, how many pieces it chops your crate into so it can work on them in parallel. People either leave them alone or paste values from a gist. This post measures both on a real program, then uses the numbers to answer the question that matters in practice: which one should change in the profile you run all day, and which in the one you ship.

All measurements are from Cargo 1.99 and rustc 1.99 with edition 2024, on a 10-core Apple M1 Max, using a program built on serde_json 1 and regex 1 that parses a JSON document and matches a pattern 600,000 times. It is listed in full in profiles explained.

What each setting trades

opt-level takes 0, 1, 2, 3, "s" or "z". Zero skips nearly every optimisation, which makes compilation fastest and the binary slowest. Three is the release default and optimises for speed, and "s" and "z" optimise for size, "z" hardest.

codegen-units controls how many chunks the compiler splits a crate into before handing them to LLVM. More units means more parallelism, so a faster build on a many-core machine, but LLVM optimises each unit in isolation, so it misses opportunities that span units: inlining, dead-code removal, sharing of generic code. The defaults are 256 for dev and 16 for release, chosen so the dev loop is fast and a release build is a compromise. One unit gives the optimiser the whole crate at once, at the cost of using a single core for that stage.

codegen-units, measured

Same source, opt-level = 3, clean release build, one value changed. Run time was 0.09 s for every row, best of three:

Text
codegen-units   clean build   binary size
1               7.3 s         1,793,440 B
4               4.7 s         2,159,648 B
16 (default)    4.6 s         2,236,592 B
256             4.4 s         2,277,632 B

Going from 16 units down to 1 cost 2.7 seconds of build time and bought a 20 percent smaller binary. Going up to 256 saved 0.2 seconds, because the machine had only ten cores to use and the units were already smaller than the work the cores could swallow. Four units landed almost on top of the default. The shape matches the theory: the cost is concentrated at the low end, where the compiler stops using the other cores. And the run time did not move at all. For this program the optimiser's missed opportunities were in size, not speed, which is a useful result because it says codegen-units = 1 is a distribution setting, not a performance fix, until you have measured otherwise on your own hot path.

opt-level, measured

Text
opt-level   clean build   binary size    run time
1           3.8 s         2,409,584 B    0.10 s
2           4.3 s         2,263,008 B    0.09 s
3           4.4 s         2,234,928 B    0.09 s
s           3.8 s         2,371,392 B    0.11 s
z           3.5 s         2,420,928 B    0.24 s

Two things stand out. The speed ranking is as documented: levels 2 and 3 are the same in practice, level 1 and "s" are a hair behind (0.10 s and 0.11 s), and "z" is clearly slower at 0.24 s, nearly three times the default. But the size ranking is the surprise. The two size-oriented levels produced larger binaries than level 3, by 6 and 8 percent. This program's size comes mostly from regex, and with less inlining the compiler emitted more calls and more copies of generic code. That is not a bug in the compiler; it is why a size-oriented level should only be adopted after you have measured the size it gives on your code. It tends to win on small, embedded and WebAssembly programs and to lose or tie on large generic-heavy libraries.

The change that pays off: optimise the dependencies in dev

The numbers above are about release builds. The best use of opt-level is in the dev profile, where the default of 0 makes dependencies painfully slow. A package override raises it for dependencies only, leaving your own code unoptimised so rebuilds stay quick:

Cargo.tomlTOML
[profile.dev.package."*"]
opt-level = 3

Here is the program's dev build before and after that one stanza:

Text
dev profile                         clean build   run time   rebuild after edit
default (opt-level 0 everywhere)    2.7 s         1.43 s     0.20 s
dependencies at opt-level 3         5.5 s         0.17 s     0.24 s
all code at opt-level 1             4.6 s         0.10 s     -

Eight times faster at runtime for about three extra seconds of one-off compile, and a rebuild after editing your own crate that cost 0.04 s more. Dependencies are compiled once and cached, so you pay for them on the first build and after a cargo update, never on an ordinary edit. The third row, level 1 for everything, is nearly as fast to run and gives up debuggability, because heavier optimisation reorders and removes code a debugger wants to step through. Keep your own crate at 0 if you debug it, and put the dependencies at 3.

What goes wrong

An invalid value is caught immediately. A unit count of zero is refused with a wording that fails every dependency, not just yours:

Text
error: value for codegen units must be a positive non-zero integer

error: could not compile `memchr` (lib) due to 1 previous error
warning: build failed, waiting for other jobs to finish...
error: could not compile `regex-syntax` (lib) due to 1 previous error

The error names the first dependency that tripped, which is a distraction: the setting is in your manifest, and every crate that inherits the profile fails the same way. The other trap is subtler. A profile with codegen-units = 1 and incremental = true compiles fine, but incremental compilation works by splitting a crate into many units to reuse the unchanged ones, so the two settings pull in opposite directions. The release build above finished in 7.0 s with both set, against 7.3 s with one unit alone, which is within the noise of a single run, so do not expect a gain from combining them. Use one or the other.

Where each setting belongs

  • Everyday dev profile: leave codegen-units alone, and set [profile.dev.package."*"] opt-level to 2 or 3 if your tests or tools spend time in dependency code.
  • Everyday release profile: leave both at the defaults unless a measurement says otherwise.
  • Distribution profile: codegen-units = 1, with LTO if you want the full size reduction, and try opt-level = "s" only if you measure a gain.

When these numbers will not transfer

Everything above is one program on ten fast cores. On a 2-core CI runner the benefit of many codegen units disappears, so the 16-unit default and the single-unit build converge in time. A program dominated by generic numeric kernels may show a real run-time gain from codegen-units = 1 or opt-level = 3 over 2, where this one showed none. And memory is a limit: a single large unit with fat LTO can need several gigabytes. Time it on the machine that matters with cargo build --release --timings, and see seven changes that speed up builds for the rest of the build-time toolbox. The reference is the profiles chapter.

Taggedprofiles
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.