Shrink streamerOS Rust binaries: opt=z, fat LTO, panic=abort
Why you care (even if you think you don’t)
If you ship Rust into containers or embedded nodes (think streamerOS agents, sidecars, or on-box inference helpers), binary size matters: smaller images, faster cold starts, less RSS pressure, and fewer I-cache misses. Three levers move the needle without rewriting code: opt-level=z, fat LTO, and panic=abort. They each save bytes for different reasons and impose different costs.
Below are the mechanics, example configs, and the sort of deltas you should expect on x86_64-unknown-linux-gnu. Your numbers will differ—measure on your CI runners.
Test harness and how to measure
- Use a nontrivial binary (several crates, generics, logging). For illustration, a ~6.2 MB stripped release binary (glibc, Rust 1.77, lld) is our baseline.
- Measure with:
cargo clean && cargo build --release ls -lh target/release/your-app llvm-size -A target/release/your-app | sed -n '1,20p' readelf -S target/release/your-app | egrep '\.text|\.rodata|\.eh_frame' `` - Optional: cargo-bloat to see top culprits.
The configurations
Here’s a sane starting point you can paste in, then toggle per profile or per target.
# Cargo.toml
[profile.release]
opt-level = "z" # Prefer size over speed (-Oz)
lto = "fat" # Whole-program optimization (more size wins than thin)
codegen-units = 1 # Fewer CGUs can reduce duplication (slower builds)
panic = "abort" # Remove unwind machinery; no catch_unwind
strip = "symbols" # Rustc -C strip (stable); or strip after build
[profile.dev]
# Keep dev fast and debuggable; do NOT cargo-cult release flags here
opt-level = 0
lto = false
debug = true
panic = "unwind"
If you need linker control (lld usually links smaller and faster than GNU ld):
[build]
rustflags = [
"-C", "link-arg=-fuse-ld=lld",
# When size-obsessed, consider tiny code model on some targets, but test.
]
What opt-level=z actually buys you
- Under the hood: LLVM -Oz (minimize size). It’s more aggressive than -Os about avoiding inlining and code growth, surfaces smaller libcalls, and prefers smaller instruction sequences even if they are slower.
- Typical delta: 3–12% smaller than opt-level=s, often bigger savings versus default release (which defaults to 3).
- When it shines: template-heavy or generics-rich code (MIR monomorphization + LLVM inlining heuristics create bloat). z clamps that growth.
- Cost: Throughput degradation (sometimes double-digit percent), but latency-sensitive code can sometimes improve due to better I-cache locality.
Example: toggling z vs 3, same binary, glibc static libs avoided.
- release (opt=3): 6.2 MB stripped
- release (opt=z): 5.6 MB stripped (~9.7% smaller)
A quick micro example
Inlining generics across crates often explodes code size. z stops a lot of that.
// src/lib.rs
pub trait Encode {
fn encode(&self) -> Vec<u8>;
}
pub fn write_all<T: Encode>(items: &[T]) -> usize {
// With -O3 and LTO, aggressive inlining across T specializations can bloat.
items.iter().map(|t| t.encode().len()).sum()
}
With opt-level=3 + LTO, multiple monomorphized callpaths may be inlined. With opt-level=z, LLVM resists that inlining, sharing more call sites, reducing duplication.
What fat LTO actually buys you
- Thin LTO is summary-based and incremental-friendly. Fat LTO is full whole-program optimization across all crates.
- For size: fat LTO usually wins because it enables global dead-code elimination (DCE) and more aggressive cross-crate devirtualization and deduplication.
- Typical delta: 2–10% smaller vs thin LTO; 5–20% vs no LTO. Heavy builds get heavier: compile time can 2–5x.
Example progression (same app, x86_64-unknown-linux-gnu, lld):
- opt=z, no LTO: 5.9 MB
- opt=z, thin LTO: 5.5 MB
- opt=z, fat LTO: 5.2 MB
Caveat: Occasionally thin beats fat for size due to different inlining heuristics; always measure.
What panic=abort actually buys you
- Removes Rust’s stack unwinding on panic. The compiler won’t generate landing pads; std links panic_abort instead of panic_unwind.
- That usually cuts: unwind tables and language-specific data for panics, and the entire panic_unwind crate from your binary.
- Typical delta: 150 KB to >1 MB depending on dependencies (backtrace, error-reporting stacks, async runtimes).
- Trade-off: No unwinding means destructors (Drop) do not run on panic. The process aborts immediately. Also, std::panic::catch_unwind is not usable across panic boundaries.
You’ll often see .eh_frame shrink when going to abort (debug/unwind info may remain for other reasons or toolchain quirks):
readelf -S target/release/your-app | grep -E "\.eh_frame|\.gcc_except_table" || true
Code that breaks with panic=abort
use std::panic::{self, AssertUnwindSafe};
fn risky() { panic!("nope"); }
fn main() {
// This compiles and runs (catching) under panic=unwind.
// Under panic=abort, this will not be able to catch a panic; the process aborts.
let r = panic::catch_unwind(AssertUnwindSafe(|| risky()));
println!("result: {:?}", r);
}
With panic=abort, you must rewrite control flow to use Result instead of panics.
Preferred pattern under abort
#[derive(thiserror::Error, Debug)]
pub enum StreamerError {
#[error("I/O: {0}")] Io(#[from] std::io::Error),
#[error("Protocol error: {0}")] Protocol(String),
}
fn parse_frame(buf: &[u8]) -> Result<Frame, StreamerError> {
if buf.is_empty() { return Err(StreamerError::Protocol("empty".into())); }
// ...
Ok(Frame {})
}
fn main() -> Result<(), StreamerError> {
let data = std::fs::read("/frames/0")?; // propagate instead of panic!
let _f = parse_frame(&data)?;
Ok(())
}
Put it together: delta table (illustrative)
| Setting | Size delta vs baseline | Build time cost | Behavioral cost |
|---|---|---|---|
| opt-level=z | -5% to -15% | Low | Slower hot paths |
| thin LTO | -3% to -10% | Medium | None |
| fat LTO | -5% to -20% | High | None |
| panic=abort | -150 KB to -1 MB+ | None | No unwind; no catch_unwind |
| strip symbols | -10% to -30% | None | Harder debugging |
Numbers stack sub-additively; don’t sum naively.
Don’t forget strip and friends
Even with debug=false, your binary can contain symbol names and DWARF. Strip aggressively in CI images, keep unstripped artifacts elsewhere.
- Use rustc strip flag (stable):
RUSTFLAGS="-C strip=symbols" cargo build --release - Or post-process:
strip target/release/your-app # GNU llvm-objcopy --strip-all your-app slim # LLVM toolchain
In practice, strip often dwarfs the savings of any single compiler knob.
Linker and libc considerations (big swings!)
- Linker: lld often yields slightly smaller (and much faster) links than GNU ld. Use: -C link-arg=-fuse-ld=lld with clang or set RUSTFLAGS to use lld on supported targets.
- glibc vs musl:
- glibc + dynamic linking → smallest host-dependent binary, but not portable to scratch.
- musl static → portable single binary, but typically larger by 0.5–2 MB. Combine with z + fat LTO + abort to claw that back.
- Feature flags: audit dependencies for default features that pull in backtrace, log formatting, or TLS. Turning those off is sometimes a bigger win than any compiler flag.
A reproducible recipe
If you want maximal shrink for a containerized streamerOS agent:
# Cargo.toml
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
[dependencies]
# Be ruthless with features
tracing = { version = "0.1", default-features = false, features = ["std"] }
thiserror = "1"
# Avoid backtrace unless you truly need it
anyhow = { version = "1", default-features = false }
Build with lld and measure:
RUSTFLAGS="-C link-arg=-fuse-ld=lld" cargo build --release
size target/release/your-app
readelf -S target/release/your-app | egrep '\\.text|\\.rodata|\\.eh_frame'
On a typical microservice-sized binary, this stack can take you from ~7.5 MB to ~4.8 MB stripped on glibc targets and from ~8.8 MB to ~6.0 MB with musl static. Your mileage will vary.
When not to do this
- You rely on catching panics (plugins, sandboxed user code, FFI backstops). Then keep panic=unwind.
- You need peak throughput and your profiles are I-cache insensitive; opt-level=3 with selective inlining may outperform z.
- You require thin LTO for incremental or build farm throughput; accept a few extra KB.
Final checklist
- Start with: opt=z, thin LTO, strip. Measure.
- If you need more: flip to fat LTO, codegen-units=1. Measure.
- If your error strategy never depends on unwinding: panic=abort. Re-run tests and chaos drills.
- Audit features. Use lld. Consider glibc dynamic vs musl static trade-offs per deployment.
If your streamerOS pipeline ships millions of instances, these switches pay rent every deploy. Measure in CI, lock the profile, and keep bloat budgets visible in PRs.