Shrink streamerOS Rust binaries: opt=z, fat LTO, panic=abort

7 min readYaseen Khatib · MERN + AI Architect

Why you care (even if you think you don’t)

If you ship Rust into containers or embedded nodes (streamerOS agents, sidecars, on-box inference helpers), binary size is not a vanity metric. Smaller images cut pull time, cold starts come down, RSS stays lower, and instruction cache pressure eases. Three switches move size without touching your code: opt-level=z, fat LTO, and panic=abort. Each trims bytes for a different reason and each has a cost you should understand.

What follows are the mechanics, example configs, and realistic deltas on x86_64-unknown-linux-gnu. Your results will differ, so measure on your CI runners.

Test harness and how to measure

  • Pick a real binary with multiple crates, generics, and logging. For reference, a ~6.2 MB stripped release binary (glibc, Rust 1.77, lld) is the baseline used here.
  • 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'
    ``
    
  • Optionally run cargo-bloat to find the top contributors.

The configurations

Here’s a practical starting point you can drop in, then adjust per profile or 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

  • It maps to LLVM -Oz, which is stricter than -Os about avoiding inlining and code growth. It also picks smaller libcalls and shorter instruction sequences even when they are slower.
  • Expect 3–12% smaller binaries compared to opt-level=s, usually more compared to the default release level (3).
  • It helps most with generic-heavy code, where monomorphization and cross-crate inlining otherwise inflate text size. z curbs that growth.
  • You will pay with throughput, sometimes double-digit percent. In a few latency-sensitive paths, better I-cache locality offsets that.

Example, toggling z vs 3 with the same binary and no static glibc:

  • 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 blocks much 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 plus LTO, multiple monomorphized paths may get inlined. With opt-level=z, LLVM resists those inlines and shares more call sites, which cuts duplication.

What fat LTO actually buys you

  • Thin LTO uses summaries and works well incrementally. Fat LTO performs whole-program optimization across all crates.
  • For size, fat LTO usually wins by enabling stronger global dead-code elimination, cross-crate devirtualization, and deduplication.
  • Typical savings: 2–10% vs thin LTO, 5–20% vs no LTO. Build times grow, often 2–5x for large graphs.

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

Sometimes thin edges out fat on size due to different inlining choices. Measure.

What panic=abort actually buys you

  • It removes stack unwinding for panics. The compiler skips landing pads and std links panic_abort instead of panic_unwind.
  • You typically drop unwind tables and language-specific panic data, and you eliminate the panic_unwind crate.
  • Savings range from ~150 KB to more than 1 MB, depending on dependencies such as backtrace, error-reporting stacks, and async runtimes.
  • The trade-off is clear: no destructors on panic and no std::panic::catch_unwind across boundaries. The process aborts.

You’ll often see .eh_frame shrink when switching to abort (some debug or unwind info can remain for unrelated reasons or toolchain behavior):

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, restructure control flow to use Result for recoverable paths 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, do not sum them directly.

Don’t forget strip and friends

Even with debug=false, symbol names and DWARF can remain. Strip hard in CI images and keep unstripped builds elsewhere.

  • Use the 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 most cases, strip beats any single compiler flag for raw bytes saved.

Linker and libc considerations (big swings!)

  • Linker: lld often produces slightly smaller binaries and links much faster than GNU ld. Use -C link-arg=-fuse-ld=lld with clang, or set RUSTFLAGS for targets that support it.
  • glibc vs musl:
    • glibc with dynamic linking gives the smallest host-dependent binary, not suitable for scratch.
    • musl static yields a portable single binary, usually larger by 0.5–2 MB. Combine z, fat LTO, and abort to claw that back.
  • Feature flags: audit dependencies for defaults that drag in backtrace, heavy log formatting, or TLS. Turning those off can beat any compiler tweak.

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. Expect variance across projects.

When not to do this

  • You rely on catching panics (plugins, sandboxed user code, FFI backstops), so keep panic=unwind.
  • You need peak throughput and profiles show I-cache is not a limiter, in which case opt-level=3 with selective inlining can win.
  • You require thin LTO for incremental builds or farm throughput, and you accept a few extra kilobytes.

Final checklist

  • Start with opt=z, thin LTO, and strip. Measure.
  • If you need more, switch to fat LTO and codegen-units=1. Measure again.
  • If your error handling never depends on unwinding, enable panic=abort and re-run tests and chaos drills.
  • Audit features, use lld, and weigh glibc dynamic vs musl static per deployment.

If your streamerOS pipeline ships millions of instances, these flags pay rent on every deploy. Measure in CI, lock the profile, and track bloat budgets in PRs.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.