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

Looking to architect a similar system?

Let's ship it at AI-speed.

Start a conversation →