
Rust Binary Size Reduction
- 2 installs
- 1 repo stars
- Updated April 1, 2026
- ehmo/rust-binary-size-reduction-skill
Reduces Rust binary size safely across CLIs, servers, WASM, and embedded targets by tuning Cargo profiles, dependency trees, and post-build packing, with each change measured.
About
Shrinks Rust artifacts through deterministic, idempotent tiers of safe, behavioral, nightly, and structural changes bracketed by size measurements. A developer uses it to slim or audit Rust binaries without breaking functionality.
- Measure-first, idempotent, deterministic optimization
- Tiered techniques from safe to structural code changes
Rust Binary Size Reduction by the numbers
- 2 all-time installs (skills.sh)
- Ranked #101 of 121 Rust skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ehmo/rust-binary-size-reduction-skill --skill rust-binary-size-reductionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 1 |
| Last updated | April 1, 2026 |
| Repository | ehmo/rust-binary-size-reduction-skill ↗ |
What it does
Reduces Rust binary size safely across CLIs, servers, WASM, and embedded targets by tuning Cargo profiles, dependency trees, and post-build packing, with each change measured.
Files
Shrink Rust Binary
Deterministic and idempotent Rust binary size reduction. Every change is measured, reversible, and explained. No change is applied blindly.
Principles
- Measure first, change second. Every optimization is bracketed by a size measurement.
- Idempotent. Running this skill twice produces the same result. Settings are set to exact values, not toggled.
- Deterministic. No randomness, no heuristics that vary between runs. Same codebase = same output.
- Correctness over size. Never break functionality. Flag behavioral changes explicitly.
- Layered. Techniques are grouped into tiers: Safe (no behavior change), Behavioral (changes panic/debug behavior), Nightly (requires nightly toolchain), and Structural (code changes).
---
Step 0: Reconnaissance
Before changing anything, gather the full picture.
1. Find Cargo.toml files:
find . -name Cargo.toml -not -path '*/target/*' | head -20Identify the workspace root vs member crates.
2. Read the root `Cargo.toml` and any [profile.release] section. Record every existing setting.
3. Read `.cargo/config.toml` if it exists. Check for existing RUSTFLAGS, linker settings, build-std config.
4. Check toolchain:
rustc --version && cargo --version
rustup show active-toolchainRecord whether stable or nightly is active. This determines which tiers are available.
5. Measure baseline binary size and compile time:
cargo clean --release 2>/dev/null
time cargo build --release 2>&1 | tail -5Then for each binary target:
ls -la target/release/<binary-name> | awk '{print $5, $9}'Record the exact byte count as the baseline and the compile time in seconds. LTO and codegen-units=1 significantly increase compile time (2-10x), so users need this to evaluate the trade-off.
6. Check for existing strip/debug settings that may already be applied. Read the full [profile.release] block.
7. Check workspace member overrides: If this is a workspace, check member crates' Cargo.toml files for [profile.release] sections that may override workspace root settings. Profile settings in member crates take precedence.
8. Classify existing optimization level:
- None: No
[profile.release]section or only default values → full optimization potential - Partial: Some settings present (e.g.,
strip = truebut no LTO) → moderate potential - Well-optimized: Already has strip + LTO + codegen-units=1 → only opt-level and behavioral changes remain
This classification determines which tiers will produce meaningful gains.
9. Present the reconnaissance report:
## Baseline Report
- Toolchain: <stable/nightly version>
- Binary: <name> = <N> bytes (<human readable>)
- Existing profile.release settings: <list or "none">
- Optimization level: <none/partial/well-optimized>
- .cargo/config.toml: <exists/absent, relevant settings>
- Workspace: <yes/no, N members>
- Member profile overrides: <list or "none">---
Step 1: Safe Profile Settings (Tier 1 — No Behavior Change)
These settings affect only optimization strategy and debug metadata. They do not change runtime behavior.
1a. Strip debug symbols
What: Removes debug symbols and symbol tables from the binary. Does NOT affect runtime behavior. Cargo >= 1.59.
Why: Pre-compiled libstd ships with ~4 MB of DWARF debug symbols that get linked into every binary. Even with debug = false, libstd symbols persist unless explicitly stripped. As of Rust 1.77+, strip = "debuginfo" is the default for release when no debuginfo is requested anywhere, but many projects still pin older toolchains or have custom profiles.
Setting:
[profile.release]
strip = truestrip = true is equivalent to strip = "symbols" — removes both debuginfo AND symbol names. Use strip = "debuginfo" if you need symbol names for profiling/backtraces.
Trade-off: Backtraces in release builds will show only addresses, not function names or line numbers. For production binaries where panics are caught upstream, this is acceptable. For CLIs where users report panics, consider strip = "debuginfo" instead.
Expected savings: 30-90% for small binaries (libstd debuginfo dominates); 5-15% for large binaries.
After applying, rebuild and measure:
cargo build --release 2>&1 | tail -3
ls -la target/release/<binary> | awk '{print $5, $9}'1b. Enable Link-Time Optimization (LTO)
What: Allows LLVM to optimize across crate boundaries at link time. Removes dead code that per-crate compilation cannot detect. Stable since Rust 1.0.
Why: Without LTO, each crate is optimized in isolation. Functions pulled from dependencies but never called survive in the binary. LTO sees the whole program and eliminates them.
Setting:
[profile.release]
lto = truelto = true is equivalent to lto = "fat" — full cross-crate optimization. lto = "thin" is faster to compile but produces slightly less size reduction. Default to true for maximum reduction.
Trade-off: Significantly increases link time (2x-10x). CI builds get slower. Use lto = "thin" if compile time is critical.
Expected savings: 10-30% on top of stripping.
1c. Reduce codegen units to 1
What: Forces the compiler to process the entire crate as a single unit, enabling maximum intra-crate optimization. Default is 16 for release.
Why: With 16 codegen units, the optimizer only sees 1/16th of the crate at a time. With 1 unit, it can inline, deduplicate, and eliminate dead code across the entire crate.
Setting:
[profile.release]
codegen-units = 1Trade-off: Compilation is single-threaded per-crate, so wall-clock compile time increases. The effect is multiplicative with LTO — both together produce the best results.
Expected savings: 1-5% on top of LTO (they share some of the same optimizations).
1d. Optimize for size
What: Tells LLVM to prefer smaller code over faster code. "s" optimizes for size; "z" additionally disables loop vectorization.
Why: Default opt-level = 3 aggressively inlines and unrolls loops, which increases code size. Size-optimized levels avoid these expansions.
Setting:
[profile.release]
opt-level = "z"Default to `"z"`. Empirical testing across real-world Rust projects (terminal multiplexers, HTTP servers, CLI tools, data format libraries) shows "z" produces 12-21% smaller binaries than "s" in typical applications. The Cargo docs note results can vary, but "z" wins in the overwhelming majority of cases.
When to try `"s"` instead: Only if the binary is compute-heavy (crypto, compression, scientific computing) where disabling loop vectorization causes measurable performance regressions. In that case, build with both and keep whichever is smaller.
Expected savings: 10-25% on top of other Tier 1 settings. Larger gains on code with many loops and generic-heavy call chains.
Tier 1 Combined Settings
After evaluating opt-level, the final Tier 1 block should look like:
[profile.release]
strip = true # Remove all symbols
opt-level = "z" # Optimize for size (default; try "s" only for compute-heavy code)
lto = true # Full link-time optimization
codegen-units = 1 # Single codegen unit for maximum optimizationNote on `debug = false`: The release profile already defaults to debug = 0, and strip = true removes any debuginfo from the binary. Explicitly setting debug = false has zero additional effect on binary size. Only add it if the project explicitly sets debug = 1 or debug = "line-tables-only" in its release profile — in that case, removing it saves compile time (no debuginfo generation).
Check for existing settings first. If the project already has strip = true, lto = true, etc., skip those and only add what's missing. Note what was already present in the report — do not claim savings for pre-existing optimizations.
Rebuild and measure after applying ALL Tier 1 settings together. Report:
## Tier 1 Results
- Baseline: <N> bytes
- After Tier 1: <N> bytes (<X>% reduction)
- Settings applied: <list only NEW settings, note pre-existing ones>
- Pre-existing: <list settings that were already in place>
- Compile time: <time in seconds>---
Step 2: Behavioral Changes (Tier 2 — Changes Runtime Behavior)
These settings remove functionality that may or may not be needed. Present each to the user with its trade-off.
2a. Abort on panic
What: Replaces stack unwinding on panic with immediate process abort. Stable since Rust 1.10.
Why: Panic unwinding requires landing pads, personality functions, and the unwinding runtime in every function that could panic. Aborting eliminates all of this.
Setting:
[profile.release]
panic = "abort"Trade-offs:
catch_unwindno longer works — panics kill the process immediately- Destructors (Drop impls) do NOT run on panic — resources may leak
- No backtrace on panic (combined with strip, you get nothing)
- Libraries that depend on unwinding for cleanup will misbehave
When safe: CLIs, short-lived processes, microservices behind a process supervisor, WASM targets. When dangerous: Long-running servers managing stateful resources, anything using catch_unwind for error recovery.
Ask the user: "Apply panic = "abort"? This removes stack unwinding on panic. Destructors won't run on panic paths. Safe for CLIs and supervised services. [Y/n]"
Expected savings: 5-10%.
2b. Overflow checks (usually no-op)
Note: overflow-checks = false is already the default for the release profile. Empirical testing on multiple binaries confirmed zero additional savings from explicitly setting it. Only mention this if the project has overflow-checks = true set explicitly — in that case, removing it saves a small amount (0-3%).
Tier 2 Combined Settings
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort" # Abort instead of unwind on panicRebuild, measure, report delta from Tier 1.
---
Step 3: Dependency Audit (Tier 3 — Structural)
3a. Install and run cargo-bloat
cargo install cargo-bloat 2>/dev/null
cargo bloat --release --crates -n 20This shows which crates contribute most to .text section size. Present the top 10 to the user.
3b. Check for duplicate dependency versions
This is one of the highest-impact checks. Many projects unknowingly compile multiple versions of the same crate because different transitive dependencies pin different major versions.
cargo tree --duplicates 2>&1 | head -40Common duplicates to look for:
- HTTP clients (reqwest v0.11 + v0.12 + v0.13): each pulls its own TLS stack
- Crypto backends (ring + aws-lc-rs): different deps pull different backends, both get compiled
- Random number generators (rand v0.7 + v0.8): older crates pin old versions
- Error handling (thiserror v1 + v2, anyhow versions)
For each duplicate: check if the newer version can replace both. If not, the fix is upstream (file an issue or patch via [patch] in Cargo.toml).
3c. Check for crypto backend duplication
A specific high-impact pattern: projects often end up with BOTH ring (~150 KB) and aws-lc-rs (~670 KB) because different TLS configurations pull different backends.
cargo tree -i ring 2>/dev/null | head -10
cargo tree -i aws-lc-sys 2>/dev/null | head -10If both appear, consolidate by choosing one backend and configuring all TLS deps to use it:
# Force ring backend (smaller, pure Rust):
reqwest = { version = "0.13", default-features = false, features = ["rustls-tls-manual-roots"] }3d. Identify replacement candidates
For each of the top 5 crates by size, check:
1. Is it using default features? Systematically audit with:
cargo tree --edges features -p <crate-name> 2>&1 | head -20This shows exactly which features are active and why. Much more reliable than grepping Cargo.toml.
2. Common heavy -> light replacements:
reqwest->ureq(saves 200-400 KB; no async runtime needed for sync HTTP)openssl/native-tls->rustls(saves 4-6 MB of C library; pure Rust)clap(derive) ->clapwithdefault-features = falseorlexopt/pico-argsregex->regex-lite(if full Unicode support not needed)serde+serde_json->nanoserdeorminiserde(for simple cases)chrono->time(often smaller); also adddefault-features = falseto chronotokio(full) ->tokiowith minimal features, orsmol/async-stdhyper->tiny_http(for simple HTTP servers)log+ heavy backend ->log+env_loggerwith minimal featuresbacktrace->std::backtrace::Backtrace(stable since Rust 1.65, saves ~120 KB)
3. Feature flag audit:
cargo install cargo-unused-features 2>/dev/null
cargo unused-features analyze
cargo unused-features reportThis identifies feature flags that are enabled but not used.
3e. Check for monomorphization bloat
Run cargo-llvm-lines to find heavily monomorphized generics:
cargo install cargo-llvm-lines 2>/dev/null
cargo llvm-lines --release 2>&1 | head -30If any generic function appears with many instantiations (>5), consider:
- Outline pattern: Extract the non-generic body into an inner function. The generic wrapper only converts arguments.
// Before: fully generic, monomorphized N times
pub fn process<T: AsRef<Path>>(path: T) { /* 200 lines */ }
// After: thin generic wrapper + single concrete implementation
pub fn process<T: AsRef<Path>>(path: T) {
process_inner(path.as_ref())
}
fn process_inner(path: &Path) { /* 200 lines */ }- Trait objects: Replace
impl Traitwithdyn Traitin non-hot paths. One vtable indirection per call vs N copies of the function.
// Before: new copy for each Read implementation
fn deserialize<R: Read>(reader: R) { ... }
// After: single implementation, dynamic dispatch
fn deserialize(reader: &mut dyn Read) { ... }- `#[inline(always)]` audit: Grep for
#[inline(always)]. Each forces duplication at every call site. Remove from functions >20 lines unless benchmarks prove the inline is critical.
grep -rn 'inline(always)' src/3f. Report dependency findings
## Dependency Audit
- Top 5 crates by size: <list with sizes>
- Duplicate crate versions: <list>
- Crypto backend duplication: <ring/aws-lc-rs/both>
- Feature flag savings available: <list>
- Monomorphization hotspots: <list>
- Recommended replacements: <list>Ask the user which changes to apply.
---
Step 4: Nightly-Only Techniques (Tier 4)
Only proceed if the user is on nightly or willing to switch. Ask first.
4a. Build std from source with build-std
What: Recompiles libstd from source with your profile settings, allowing LTO, size optimization, and dead code elimination to apply to the standard library.
Why: The pre-compiled libstd is built with opt-level = 3 (speed, not size) and includes the full library. Building from source lets your LTO remove unused parts.
Prerequisites:
rustup toolchain install nightly
rustup component add rust-src --toolchain nightlyFind the host target triple:
rustc -vV | grep host | awk '{print $2}'Build command:
cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features="optimize_for_size" \
--target <host-triple>Note: Binary will be at target/<host-triple>/release/<binary> instead of target/release/<binary>.
What `optimize_for_size` does: This is a libstd feature flag (not an LLVM flag) that tells the standard library to use size-optimized algorithm variants — smaller formatting internals, simpler hash implementations, etc. It changes which code paths libstd uses, complementing opt-level = "z" which changes how LLVM optimizes those paths.
Expected savings: 20-50% on top of Tier 1+2.
4b. Share generic instantiations across crates
What: Forces the compiler to share monomorphized generic instances across crates instead of each crate getting its own copy.
RUSTFLAGS="-Zshare-generics=y" cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features="optimize_for_size" \
--target <host-triple>Trade-off: When combined with full LTO (lto = true), the savings are reduced since LTO already deduplicates across crates. Most effective with lto = "thin" or no LTO.
Expected savings: 5-20% without LTO, 1-5% with LTO.
4c. Remove location details
What: Strips file/line/column info from panic messages and #[track_caller] sites.
RUSTFLAGS="-Zlocation-detail=none" cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features="optimize_for_size" \
--target <host-triple>Trade-off: Panic messages become useless for debugging. Acceptable for production binaries with external error reporting.
4d. Remove fmt::Debug
What: Makes #[derive(Debug)] and {:?} formatting into no-ops. Removes all derived Debug format strings and functions.
RUSTFLAGS="-Zlocation-detail=none -Zfmt-debug=none" cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features="optimize_for_size" \
--target <host-triple>Trade-off: dbg!(), assert!() error messages, and unwrap() error messages become empty. Any code that parses Debug output will break.
4e. Immediate abort on panic (no formatting)
What: Removes ALL panic formatting machinery. Panics call abort() immediately with no string formatting at all.
RUSTFLAGS="-Zunstable-options -Cpanic=immediate-abort -Zlocation-detail=none -Zfmt-debug=none" \
cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features="optimize_for_size" \
--target <host-triple>Note: We keep optimize_for_size since we're optimizing for binary size. The backtrace and panic-unwind features are already excluded by using panic_abort in build-std. If you need to also remove the default backtrace feature, use -Z build-std-features=optimize_for_size (it replaces defaults, not appends).
Expected total with all Tier 4: A hello-world drops to ~30 KB on macOS. Real applications see 50-70% reduction from Tier 1 alone.
Tier 4 Combined Command (maximum reduction)
HOST=$(rustc -vV | grep host | awk '{print $2}')
RUSTFLAGS="-Zunstable-options -Cpanic=immediate-abort -Zlocation-detail=none -Zfmt-debug=none" \
cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features="optimize_for_size" \
--target "$HOST"Measure the result:
ls -la target/$HOST/release/<binary> | awk '{print $5, $9}'---
Step 5: Post-Build Techniques (Tier 5 — Language Agnostic)
5a. UPX compression
What: UPX creates a self-extracting compressed executable. The binary decompresses itself into memory at startup.
# Install if needed
brew install upx # macOS
# apt install upx # Linux
upx --best --lzma target/release/<binary>Trade-offs:
- Adds ~50-100ms startup time for decompression
- Some antivirus software flags UPX-packed binaries as suspicious (malware commonly uses UPX)
- Cannot be combined with code signing on macOS (signature is invalidated)
- Memory usage at startup is briefly 2x (compressed + decompressed)
When useful: Distribution where download size matters more than startup time. Container images. Embedded systems with flash size constraints.
Expected savings: 50-70% on top of all other optimizations.
Ask the user: "Apply UPX compression? Adds ~50-100ms startup latency and may trigger antivirus heuristics. [Y/n]"
5b. Debuginfo compression (if keeping debuginfo)
If the binary includes debuginfo (e.g., debug = "line-tables-only" for backtraces in production):
Garbage collect unused debuginfo:
llvm-dwarfutil <binary> <binary>-gcExpected savings: 10-20% of debuginfo sections.
Compress debuginfo sections:
objcopy --compress-debug-sections=zlib <binary> <binary>-compressedExpected savings: 60-70% of debuginfo sections. zstd compresses ~5% better but has less tool support (e.g., gimli/backtrace in libstd can't decompress zstd).
Combined (GC then compress):
llvm-dwarfutil <binary> <binary>-gc
objcopy --compress-debug-sections=zlib <binary>-gc <binary>-finalAlternative — compress via linker flag:
RUSTFLAGS="-Clink-arg=-Wl,--compress-debug-sections=zlib" cargo build --release5c. Linker flags for size reduction
The linker can perform size-reducing transformations beyond what the compiler does. These are some of the most impactful post-Tier-2 optimizations.
Identical Code Folding (ICF) — 5-20% for generic-heavy code
ICF merges functions with identical machine code. Rust's monomorphization creates many identical copies (e.g., Option<&T>::unwrap for different T with same size). ICF deduplicates them at link time.
Linux (requires lld or mold):
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld", "-C", "link-arg=-Wl,--icf=all"]--icf=all allows merging even if function addresses differ (safe for most Rust code). Use --icf=safe if the code takes function pointers and compares them.
macOS: The Apple linker does not support ICF. Use lld on macOS for ICF:
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=lld", "-C", "link-arg=-Wl,--icf=all"]LLVM Function Merging — 2-10% (complementary to ICF)
LLVM's -mergefunc pass merges functions at IR level before codegen. It catches cases ICF misses.
RUSTFLAGS="-Cllvm-args=-mergefunc-use-aliases" cargo build --releaseCan be combined with ICF for maximum deduplication.
IMPORTANT: LTO interaction. When lto = true (fat LTO) is enabled, both ICF and mergefunc have near-zero effect because LTO already performs cross-crate deduplication at the LLVM IR level. Empirical testing on real projects (spotify-player, lance-tools) showed 0% additional savings with mergefunc when LTO was active. These techniques are most valuable when:
- LTO is disabled (fast builds)
- LTO is set to
"thin"(partial optimization) - Cross-language LTO is not available (C deps remain opaque)
If you already have lto = true, skip ICF and mergefunc — they won't help.
Garbage Collection of Unused Sections — 1-5%
Linux (explicit GC — lld does this by default, bfd does NOT):
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-Wl,--gc-sections"]macOS (explicit dead stripping):
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-Wl,-dead_strip"]Note: macOS still benefits from explicit -dead_strip for size reduction, even though the default Apple linker is fast. However, with lto = true, the effect is minimal since LTO already eliminates dead code.
Cross-Language LTO — 5-20% (for projects with C dependencies)
If the project links C code (via cc crate, *-sys crates like openssl-sys, zstd-sys, ring), regular LTO only optimizes Rust code. Cross-language LTO extends optimization to C code too.
CFLAGS="-flto" RUSTFLAGS="-Clinker-plugin-lto -Clink-arg=-fuse-ld=lld" cargo build --releaseRequires lld and clang (not gcc) for the C code.
Expected savings: Significant for projects with heavy C dependencies (crypto, compression). Zero for pure Rust projects.
Linker selection summary
| Linker | Platform | Speed | ICF | GC-sections | Notes |
|---|---|---|---|---|---|
lld | Linux/macOS | Fast | Yes (--icf=all) | Default on | Best overall for size |
mold | Linux | Fastest | Yes (--icf=safe/all) | Default on | Good alternative |
bfd (default) | Linux | Slow | No | No (must pass --gc-sections) | Avoid for size work |
| Apple ld | macOS | Fast | No | Partial (add -dead_strip) | Limited size features |
---
Step 6: Advanced Code Patterns (Tier 6 — Code Changes)
These require modifying source code. Present as recommendations, not automatic changes.
6a. Avoid unnecessary derives
Every #[derive(Debug, Clone, PartialEq, ...)] generates code. Audit structs:
grep -rn '#\[derive(' src/ | head -30- Remove
Debugfrom types never printed with{:?}in production - Remove
Clonefrom types never cloned - Remove
PartialEq/Eqfrom types never compared - Consider manual impls that delegate to fewer fields
6b. Feature-gate heavy functionality
If the binary serves multiple purposes (CLI + library, server + client), gate heavy dependencies behind features:
[features]
default = ["client"]
server = ["dep:tokio", "dep:hyper"]
client = ["dep:ureq"]6c. Use #[cold] on error paths
Mark error-handling functions as cold to prevent inlining into hot paths:
#[cold]
#[inline(never)]
fn handle_error(e: Error) -> ! { ... }6d. Audit string formatting
format!(), println!(), eprintln!() pull in core::fmt machinery. In size-critical code:
- Replace
format!("{}", x)withx.to_string()where possible - Use
write!to a pre-allocated buffer instead offormat! - For integer-to-string, consider
itoacrate (smaller than fmt machinery)
6e. Prefer &str over String in const/static contexts
Static strings don't need heap allocation. Check for patterns like:
// Wasteful - allocates at runtime
let msg = String::from("hello");
// Better - zero-cost
let msg: &str = "hello";---
Step 7: Final Report
After all applied tiers, produce a comprehensive report:
## Binary Size Optimization Report
### Environment
- Toolchain: <version>
- Target: <triple>
- OS: <os>
- Optimization level at start: <none/partial/well-optimized>
- Pre-existing settings: <list or "none">
### Size Progression
| Stage | Size (bytes) | Size (human) | Delta | % of baseline | Compile time |
|------------------------|-------------|--------------|----------|---------------|-------------|
| Baseline (release) | <N> | <X MB> | - | 100% | <Ns> |
| Tier 1 (safe profile) | <N> | <X MB> | -<X MB> | <X>% | <Ns> |
| Tier 2 (behavioral) | <N> | <X MB> | -<X MB> | <X>% | <Ns> |
| Tier 3 (dep audit) | <N> | <X MB> | -<X MB> | <X>% | <Ns> |
| Tier 4 (nightly) | <N> | <X MB> | -<X MB> | <X>% | <Ns> |
| Tier 5 (post-build) | <N> | <X MB> | -<X MB> | <X>% | n/a |
### Settings Applied (new)[profile.release] strip = true opt-level = "z" lto = true codegen-units = 1 panic = "abort" # if applied
### Settings Already Present (pre-existing)
- <list settings that were already in the project's Cargo.toml>
### Dependency Changes
- <list of changes made>
- Duplicate crate versions found: <list>
- Crypto backend: <ring/aws-lc-rs/both/none>
### Code Changes
- <list of changes made>
### Not Applied (and why)
- <list of skipped techniques with reason>
### Recommendations for CI
Add size tracking to CI to prevent regressions:Example GitHub Actions step
- name: Check binary size
run: | cargo build --release SIZE=$(stat -f%z target/release/<binary> 2>/dev/null || stat -c%s target/release/<binary>) echo "Binary size: $SIZE bytes" if [ "$SIZE" -gt <threshold> ]; then echo "::error::Binary size $SIZE exceeds threshold <threshold>" exit 1 fi
---
## Reference: Tools
| Tool | Purpose | Install |
|------|---------|---------|
| `cargo-bloat` | Per-crate and per-function size breakdown | `cargo install cargo-bloat` |
| `cargo-llvm-lines` | Count monomorphized generic instantiations | `cargo install cargo-llvm-lines` |
| `cargo-unused-features` | Find unused Cargo feature flags | `cargo install cargo-unused-features` |
| `twiggy` | WASM code size profiler | `cargo install twiggy` |
| `cargo-show-asm` | Inspect assembly output per function | `cargo install cargo-show-asm` |
| `llvm-dwarfutil` | GC unused debuginfo entries | `apt install llvm-<ver>` / from LLVM |
| `upx` | Executable compression | `brew install upx` / `apt install upx` |
| `bloaty` | Google's binary size profiler (multi-lang) | `brew install bloaty` |
## Reference: Typical Savings by Technique
Empirical data from testing across real-world projects (terminal multiplexers, HTTP servers, CLI tools, data libraries, music players):
| Technique | Typical Savings | Notes | Requires |
|-----------|----------------|-------|----------|
| **Tier 1 combined** (strip+lto+cgu1+oz) | **43-68%** from unoptimized baseline | Single biggest win. Projects with existing opts see 10-25% | Stable 1.59+ |
| `strip = true` | 20-50% (largest single setting) | Removes libstd debug symbols + symbol table | Stable 1.59+ |
| `opt-level = "z"` | 10-25% | Beats "s" by 12-21% in most real-world code | Stable 1.28+ |
| `lto = true` | 10-30% | Cross-crate dead code elimination | Stable 1.0+ |
| `codegen-units = 1` | 1-5% | Diminishing returns on top of LTO | Stable 1.0+ |
| **Tier 2: `panic = "abort"`** | **5-14%** on top of Tier 1 | Removes unwinding machinery. Sole contributor to Tier 2 savings | Stable 1.10+ |
| `overflow-checks = false` | **0%** (default in release) | Already disabled in release profile. Only helps if explicitly set to true | Stable |
| **Tier 4: `build-std`** | **20-50%** on top of Tier 1+2 | Recompiles libstd with your settings | Nightly |
| `-Zlocation-detail=none` | 1-5% | Removes file/line from panics | Nightly |
| `-Zfmt-debug=none` | 2-10% | Removes Debug trait formatting | Nightly |
| `panic=immediate-abort` | 5-15% | Removes ALL panic formatting | Nightly |
| Dependency replacement | 5-50% (varies wildly) | Project-specific | - |
| Monomorphization reduction | 5-30% (for generic-heavy code) | Needs cargo-llvm-lines | - |
| UPX compression | 50-70% | Post-build, adds startup latency | External tool |
| Debuginfo GC + compression | 60-70% (of debuginfo sections) | Only if keeping debuginfo | External tools |
### Empirical Results (benchmark across 7 real-world binaries)
All results are **Tier 1+2 combined** (strip + lto + codegen-units=1 + opt-level z + panic=abort):
| Project | Type | Baseline | After Tier 1+2 | Reduction | Compile time delta |
|---------|------|----------|----------------|-----------|-------------------|
| codexmanager-web | HTTP server | 9.0 MB | 3.1 MB | 66% | +14% |
| codexmanager-service | HTTP server | 15.5 MB | 4.9 MB | 68% | — |
| codexmanager-start | CLI launcher | 7.4 MB | 2.7 MB | 63% | — |
| spotify_player | TUI app | 29.6 MB | 7.9 MB | 73% | — |
| zellij* | Terminal mux | 38.0 MB | 27.2 MB | 28% | — |
| lance-tools | Data tool | 2.2 MB | 1.0 MB | 54% | — |
| vector | Observability | 139.3 MB | 36.2 MB | 74% | — |
*zellij baseline already had strip+lto+codegen-units=1; only opt-level z and panic=abort were new
**Median reduction: 66%. Range: 28-74%.** Projects with no existing optimizations see 54-74%. The compile time increase from LTO+codegen-units=1 is modest (~14%) for incremental builds but can be 2-10x for clean builds.
## Reference: Quick Copy-Paste
**Minimum viable (stable, safe — typical 43-68% reduction):**[profile.release] strip = true opt-level = "z" lto = true codegen-units = 1
**Aggressive (stable, behavioral change — typical 54-74% reduction):**[profile.release] strip = true opt-level = "z" lto = true codegen-units = 1 panic = "abort"
**Maximum (nightly):**HOST=$(rustc -vV | grep host | awk '{print $2}') RUSTFLAGS="-Zunstable-options -Cpanic=immediate-abort -Zlocation-detail=none -Zfmt-debug=none" \ cargo +nightly build --release \ -Z build-std=std,panic_abort \ -Z build-std-features="optimize_for_size" \ --target "$HOST"
.DS_Store
target/
node_modules/
*.zip
Rust Binary Size Reduction
Shrink Rust binaries with measured tradeoffs. Covers CLIs, servers, libraries, WASM, and embedded targets.
Reference: https://doc.rust-lang.org/cargo/reference/profiles.html
Technique priority
| # | Technique | Typical raw win | Risk |
|---|---|---|---|
| 1 | strip = true (remove symbols + debuginfo) | 20-50% | Weaker backtraces |
| 2 | lto = true + codegen-units = 1 | 10-30% additional | Slower compilation |
| 3 | opt-level = "z" (size over speed) | 10-25% additional | Reduced runtime performance |
| 4 | panic = "abort" (no unwinding) | 5-14% additional | No catch_unwind, no Drop on panic |
| 5 | Dependency audit (bloat, duplicates, features) | Varies | Source changes required |
| 6 | build-std (nightly, recompile libstd) | 20-50% additional | Requires nightly toolchain |
| 7 | UPX, linker flags, code patterns | Varies | Specialist tradeoffs |
Hard rules
- Never apply optimizations without measuring before and after
- Never claim savings for settings already present in the project
- Never recommend UPX on macOS (kernel kills packed binaries after signing invalidation)
overflow-checks = falsehas zero effect in release profile (already the default)debug = falsehas zero effect whenstrip = trueis set- ICF and mergefunc have near-zero effect when
lto = trueis active - Always measure raw size and compile time impact
Scripts
scripts/collect-build-context.sh-- Gather toolchain and project factsscripts/reproducible-build.sh-- Build with controlled flagsscripts/measure-binary-size.sh-- Measure artifact sizesscripts/compare-size-report.sh-- Diff two artifacts
interface:
display_name: "Rust Binary Size Reduction"
short_description: "Shrink Rust binaries with measured tradeoffs."
default_prompt: "Use $rust-binary-size-reduction to reduce a Rust binary safely and measure the real before/after impact."
policy:
allow_implicit_invocation: true
Rust Binary Size Reduction Skill
Shrink Rust binaries with measured tradeoffs.
SKILL.md-- Full guidelines with YAML frontmattermetadata.json-- Version, references, abstractreferences/-- Decision tree, workflow, verification, build inputs, sourcesscripts/-- Shell scripts for reproducible measurementagents/-- Agent configs (OpenAI Codex)
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"version": "1.0.0",
"organization": "ehmo",
"date": "March 2026",
"abstract": "Rust binary size reduction skill. Teaches agents to shrink Rust binaries through structured, measured optimization: Cargo profile tuning, dependency audits, monomorphization reduction, nightly build-std, and linker flags. Tested against 7 binaries from trending Rust repositories with a median 66% size reduction.",
"references": [
"https://doc.rust-lang.org/cargo/reference/profiles.html",
"https://doc.rust-lang.org/rustc/codegen-options/index.html",
"https://github.com/johnthagen/min-sized-rust",
"https://kobzol.github.io/rust/cargo/2024/01/23/making-rust-binaries-smaller-by-default.html",
"https://github.com/nickel-org/nickel.rs/issues/285"
]
}
Rust Binary Size Reduction Skill
Shrink Rust binaries by 54-74% without breaking anything. Works with Claude Code, OpenAI Codex, and any agent that reads skill files.
Your agent collects build facts first, applies safe Cargo profile settings in order of impact, measures after each change, and stops before breaking runtime behavior. No cargo-culted compiler hacks, no settings that sound useful but do nothing.
What it does
Your agent gets a structured workflow:
1. Collect build context (Rust version, existing profile settings, workspace structure, dependencies). 2. Build and measure a reproducible baseline. 3. Apply reductions one tier at a time, measuring after each step. 4. Report the before/after numbers and the tradeoffs.
It includes shell scripts for reproducible measurement, a decision tree for edge cases, and hard rules that prevent the agent from recommending redundant or harmful techniques.
Benchmark results
I tested this skill against 7 binaries from trending Rust repositories on GitHub. Each binary was built with default release settings as a baseline, then optimized using the skill. All binaries started and responded to basic commands after optimization.
| Repository | Binary | Baseline | After Tier 1+2 | Reduction |
|---|---|---|---|---|
| Codex-Manager | codexmanager-web | 9.0 MB | 3.1 MB | 66% |
| Codex-Manager | codexmanager-service | 15.5 MB | 4.9 MB | 68% |
| Codex-Manager | codexmanager-start | 7.4 MB | 2.7 MB | 63% |
| spotify-player | spotify_player | 29.6 MB | 7.9 MB | 73% |
| zellij | zellij | 38.0 MB | 27.2 MB | 28%* |
| lance | lance-tools | 2.2 MB | 1.0 MB | 54% |
| vector | vector | 139.3 MB | 36.2 MB | 74% |
Median reduction: 66%. Range: 54-74% for projects without existing optimizations.
*zellij already had strip = true, lto = true, and codegen-units = 1 in its baseline. The 28% came from adding opt-level = "z" and panic = "abort" on top.
Reductions came from stripping debug symbols (strip = true), enabling link-time optimization (lto = true), setting single codegen unit (codegen-units = 1), optimizing for size (opt-level = "z"), and disabling panic unwinding (panic = "abort").
How it was built
The skill started with the Cargo profile docs, the min-sized-rust community guide, and Jakub Beranek's write-ups on why Rust binaries are large by default. I organized those into a tiered system: safe settings first, behavioral changes second, nightly techniques third, structural changes last.
Then I tested it. I wrote a benchmark harness that clones trending Rust repos from GitHub, builds default release baselines, and applies the skill's recommendations tier by tier. The harness measures raw size at each step.
I ran 5 optimization cycles. Each cycle found gaps: settings that sounded useful but measured at zero impact (overflow-checks = false, debug = false with strip = true), linker flags that did nothing when LTO was active (ICF, mergefunc, -dead_strip), and opt-level guidance that needed updating ("z" beat "s" in every project tested). I removed the dead weight and updated the skill after each cycle.
The benchmark harness lives in a separate development repo. This repo contains the tested result.
Installation
npx skills add ehmo/rust-binary-size-reduction-skillOr clone this repo and point your agent at it.
Usage
Invoke it directly:
Shrink the release binary for this Rust project and report before/after sizes.Audit this Rust project for binary size reduction opportunities.Apply Tier 1 Cargo profile optimizations and measure the impact.Four bundled shell scripts produce consistent output across repos:
scripts/collect-build-context.sh-- gathers Rust version, profile settings, dependencies, and duplicate cratesscripts/reproducible-build.sh-- builds with controlled flags and copies the artifactscripts/measure-binary-size.sh-- reports raw, gzip, and xz sizes plus top symbolsscripts/compare-size-report.sh-- diffs two artifacts with percentage changes
Repo structure
SKILL.md -- Agent instructions with YAML frontmatter
AGENTS.md -- Quick context for agent consumption
metadata.json -- Version, references, abstract
references/
build-inputs.md -- Facts to collect before starting
decision-tree.md -- What to try, what to skip, what's redundant
workflow.md -- Step-by-step procedure
verification.md -- How to validate the result
sources.md -- Authoritative references and empirical findings
scripts/
collect-build-context.sh
reproducible-build.sh
measure-binary-size.sh
compare-size-report.sh
agents/
openai.yaml -- OpenAI Codex agent configAgents
Works with any agent that reads skill files. Tested with:
- Claude Code -- reads
SKILL.mddirectly - OpenAI Codex -- uses the
agents/openai.yamlconfig
What was disproven
These techniques were tested empirically and found to have zero measurable effect:
overflow-checks = falsein release profile -- already the default, 0 bytes saveddebug = falsewhenstrip = trueis set -- strip handles it, 0 bytes saved- LLVM mergefunc with
lto = true-- LTO already deduplicates, 0% savings - Identical Code Folding with
lto = true-- same, 0% savings - macOS
-dead_stripwithlto = true-- LTO already removes dead code, 0 bytes saved
The skill documents these as redundant so agents do not waste time on them.
Sources
- Cargo profile docs (doc.rust-lang.org)
- rustc codegen options (doc.rust-lang.org)
- min-sized-rust (github.com/johnthagen/min-sized-rust)
- Jakub Beranek on Rust binary size (kobzol.github.io)
Full list in references/sources.md.
Contributing
- Keep PRs focused to one change.
- If updating the decision tree or technique rankings, explain why and include before/after measurements.
- Do not add techniques without testing them against at least a few real repos.
License
MIT
Build Inputs
Collect these facts before proposing shrink work.
Artifact Facts
- target binary or binaries (check for
[[bin]]andsrc/main.rs) - workspace structure (root vs member crates)
- target OS and architecture
- whether the artifact is a CLI, server, library, WASM module, or embedded binary
Toolchain Facts
- Rust version (
rustc --version) - active toolchain (
rustup show active-toolchain) -- stable or nightly - existing
[profile.release]settings in Cargo.toml - existing
.cargo/config.toml(RUSTFLAGS, linker, build-std) - whether the project pins a specific toolchain via
rust-toolchain.toml
Runtime Facts
- whether
catch_unwindis used (affectspanic = "abort"safety) - whether the project uses C dependencies (
*-syscrates,cccrate) - whether the project uses
#[derive(Debug)]extensively - whether embedded assets exist (via
include_bytes!,include_str!, or asset crates)
Release Facts
- whether backtraces are needed in the shipped binary
- whether the target is signed or notarized (macOS)
- whether the artifact is further compressed by the delivery path
- whether startup latency matters (affects UPX viability)
Dependency Facts
- crate count (
wc -l Cargo.lockas rough proxy) - presence of heavy crates:
reqwest,tokio,serde,clap,regex,chrono - duplicate crate versions (
cargo tree --duplicates) - crypto backend situation (
ringvsaws-lc-rsvs both) - feature flags in use (
cargo tree --edges features)
Agent Notes
- Prefer collecting facts with
./scripts/collect-build-context.sh. - If the project already has release profile settings, record them and do not claim credit for pre-existing optimizations.
- If the release process signs binaries (macOS), any UPX or post-build patching is off the table.
Decision Tree
Use this file to decide what to try next.
Default Techniques (Tier 1 -- No Behavior Change)
These are safe first moves. Apply in order.
| Technique | Use when | Notes |
|---|---|---|
strip = true | almost always | Largest single setting. Removes libstd debug symbols + symbol table |
lto = true | almost always | Cross-crate dead code elimination. Increases link time 2-10x |
codegen-units = 1 | almost always | Diminishing returns on top of LTO, but free size win |
opt-level = "z" | almost always | Beats "s" by 12-21% in real-world code. Try "s" only for compute-heavy binaries |
Opt-In Techniques (Tier 2 -- Behavioral Change)
Use only after confirming the tradeoff is acceptable.
| Technique | Use when | Typical raw win | Risk |
|---|---|---|---|
panic = "abort" | unwinding is not needed | 5-14% | No catch_unwind, no Drop on panic paths |
| Dependency replacement | lighter alternative exists | 5-50% | Source changes, API differences |
| Feature flag trimming | unused features are enabled | Varies | May break downstream consumers |
| Monomorphization reduction | cargo-llvm-lines shows bloat | 5-30% | Slightly slower dynamic dispatch |
Advanced Techniques (Tier 4 -- Nightly)
Requires nightly toolchain. Ask before applying.
| Technique | Use when | Typical raw win | Risk |
|---|---|---|---|
build-std | stable Tier 1+2 is not enough | 20-50% on top of Tier 1+2 | Nightly-only, slower builds |
-Zlocation-detail=none | panic debugging not needed | 1-5% | Empty panic messages |
-Zfmt-debug=none | Debug formatting not needed | 2-10% | dbg!(), assert!() output empty |
-Cpanic=immediate-abort | no panic formatting at all | 5-15% | Panics just call abort() |
-Zshare-generics=y | LTO is disabled or thin | 5-20% without LTO | 1-5% with LTO (redundant) |
Forbidden or Redundant Techniques
Do not present these as standard advice.
| Technique | Status | Why |
|---|---|---|
overflow-checks = false in release | redundant | Already the default. Zero measured effect |
debug = false with strip = true | redundant | Zero measured effect. Strip already handles it |
ICF/mergefunc with lto = true | redundant | LTO already performs the same deduplication |
-dead_strip with lto = true on macOS | redundant | Zero measured effect with LTO active |
| UPX on macOS | forbidden | Packed binaries are killed by the kernel (SIGKILL) |
| Post-sign binary patching | forbidden | Breaks signatures and notarization |
Dynamic linking (prefer-dynamic) | do not use | No stable Rust ABI, deployment complexity |
Branches
If Tier 1 gives a large win (50%+)
The baseline had no optimizations. The project was shipping debug symbols and unoptimized code. Keep the settings and report the win.
If Tier 1 barely helps
The project already has partial optimizations. Check what is already set and only add what is missing. Focus on dependency audit and structural changes.
If the project has C dependencies
Check whether cross-language LTO is worth pursuing:
- Run
cargo tree -i ringandcargo tree -i aws-lc-sys - If both crypto backends are present, consolidate to one
- Cross-language LTO requires
lld+clangand only helps when LTO cannot see through C code
If the target is WASM
Use twiggy instead of cargo-bloat for size profiling. Consider wasm-opt as a post-build step. build-std is particularly effective for WASM.
If the target is macOS and signed
Only make size changes before signing. UPX is not viable. Re-run codesigning validation after the final build.
If the binary is already compressed in transit
Measure gzip or xz size before considering UPX. Packers add little when the binary is already distributed inside a compressed medium.
Sources
Use these sources to justify recommendations or resolve edge cases.
Highest-Trust References
- Cargo profiles: https://doc.rust-lang.org/cargo/reference/profiles.html
- rustc codegen options: https://doc.rust-lang.org/rustc/codegen-options/index.html
- rustc linker-plugin-lto: https://doc.rust-lang.org/rustc/linker-plugin-lto.html
- Cargo build-std: https://doc.rust-lang.org/cargo/reference/unstable.html#build-std
- Rust release notes (strip defaults): https://blog.rust-lang.org/2024/02/08/Rust-1.77.0.html
Community References
- min-sized-rust: https://github.com/johnthagen/min-sized-rust
The most comprehensive community guide to small Rust binaries. Covers everything from profile settings to no_std. Treat as the canonical reference for techniques and their tradeoffs.
- Jakub Beranek (kobzol) on binary size: https://kobzol.github.io/rust/cargo/2024/01/23/making-rust-binaries-smaller-by-default.html
Best source for understanding why Rust binaries are large by default and what the compiler team is doing about it.
- Jakub Beranek on multithreaded binary size: https://kobzol.github.io/rust/2024/10/26/multithreaded-rust-binary-size.html
Deep dive into how async runtimes and threading affect binary size.
Tool Documentation
- cargo-bloat: https://github.com/RazrFalcon/cargo-bloat
Per-crate and per-function size breakdown. Primary tool for identifying what occupies space.
- cargo-llvm-lines: https://github.com/dtolnay/cargo-llvm-lines
Counts monomorphized generic instantiations. Essential for finding generic bloat.
- cargo-unused-features: https://github.com/timonpost/cargo-unused-features
Finds enabled but unused Cargo feature flags.
- twiggy: https://github.com/nickel-org/nickel.rs/issues/285
WASM-specific code size profiler. Supports call graph and dominator analysis.
- UPX: https://upx.github.io/
Executable packer. Works on Linux and Windows. Does not work on macOS (kernel kills packed binaries).
Empirical Findings from This Skill
These findings were validated by benchmarking across 7 binaries from trending Rust repos:
opt-level = "z"beats"s"in 100% of tested projects (6/6 where both were tried), by 12-21%overflow-checks = falsehas zero measured effect in release profile (already the default)debug = falsehas zero measured effect whenstrip = trueis set- LLVM mergefunc has zero measured effect when
lto = trueis active - macOS
-dead_striphas zero measured effect whenlto = trueis active - Tier 1+2 combined (strip+lto+cgu1+oz+abort) gives median 66% reduction, range 28-74%
- The 28% outlier (zellij) already had strip+lto+codegen-units=1 in its baseline
Verification
A smaller binary is not a valid result until it passes both artifact checks and behavior checks.
Artifact Checks
For every before/after pair, capture:
1. raw bytes 2. gzip bytes 3. xz bytes when available 4. compile time (clean build) 5. top crates by size from cargo bloat --release --crates
Use:
./scripts/measure-binary-size.sh dist/app
./scripts/compare-size-report.sh dist/app-before dist/app-afterBehavior Checks
Run the smallest useful set of runtime checks for the target:
1. process starts successfully 2. main request path or CLI command still works 3. panic behavior is acceptable for the release policy 4. startup latency and memory usage remain acceptable if UPX was used
Special Checks
After strip = true
- confirm backtraces are still adequate for the release policy
- keep an unstripped companion artifact if postmortem debugging is needed
- if the project set
debug = 1ordebug = "line-tables-only", note the change
After panic = "abort"
- verify the project does not use
catch_unwind - verify Drop-on-panic behavior is not relied on for cleanup
- test that panicking code paths still produce acceptable output
After opt-level = "z"
- verify performance-critical paths still meet latency requirements
- benchmark hot loops if applicable
- acceptable for CLIs, build tools, and size-critical deployments
- less suitable for latency-sensitive services or inner-loop compute
After dependency changes
- run the project's test suite
- test feature combinations if feature flags were changed
- verify TLS behavior if crypto backends were changed
After build-std (nightly)
- verify the binary runs correctly on the target platform
- test any code that depends on std internals or platform-specific behavior
- note that the binary path changes to
target/<triple>/release/
After UPX
- only on Linux/Windows -- macOS kills packed binaries
- test cold start time
- test RSS during startup
- check antivirus and malware scanning behavior if relevant
- verify signing only after the final packed artifact exists
After macOS changes
- re-run codesigning checks
- re-run notarization checks if the distribution requires them
Release Gate
Ship only if:
1. the measured win is real 2. the runtime behavior is still acceptable 3. compile time increase is documented and accepted 4. any debugging or backtrace tradeoffs are documented 5. signing and packaging constraints still pass
Workflow
Use this workflow unless the project already has a stricter release pipeline.
Phase 1: Baseline
1. Identify the target binary or binaries. 2. Capture the build context: ./scripts/collect-build-context.sh 3. Produce a baseline artifact: ./scripts/reproducible-build.sh -o dist/app-baseline 4. Measure it: ./scripts/measure-binary-size.sh dist/app-baseline
Do not change code or Cargo.toml before you have a baseline.
Phase 2: Safe Profile Settings (Tier 1)
Apply these to [profile.release] in the workspace root Cargo.toml:
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1Skip any that are already present. Note which were pre-existing.
Rebuild and measure:
./scripts/reproducible-build.sh -o dist/app-tier1 --profile-optimized
./scripts/compare-size-report.sh dist/app-baseline dist/app-tier1Measured benchmark results (7 trending Rust repos):
- Tier 1 alone: median 55% raw reduction from unoptimized baseline
- Projects with existing optimizations: 10-25% additional
If the win is large enough and no further reduction is needed, stop here.
Phase 3: Behavioral Changes (Tier 2)
Add panic = "abort" after confirming: 1. The project does not use catch_unwind 2. Leaking resources on panic is acceptable 3. The project is a CLI, short-lived process, or supervised service
[profile.release]
panic = "abort"This adds 5-14% on top of Tier 1. The combined Tier 1+2 typically gives 54-74%.
Phase 4: Dependency Audit (Tier 3)
Before structural changes, audit the dependency tree:
1. Run cargo bloat --release --crates -n 20 for per-crate size breakdown 2. Run cargo tree --duplicates for version duplication 3. Check for crypto backend duplication (ring + aws-lc-rs) 4. Audit feature flags with cargo tree --edges features 5. Check for monomorphization bloat with cargo llvm-lines --release
Common wins:
- Disable reqwest default features (drops http2, charset, extra TLS backends)
- Replace
backtracecrate withstd::backtrace::Backtrace - Add
default-features = falseto chrono, clap, and other heavy crates - Consolidate duplicate dependency versions via
[patch]
Phase 5: Nightly Techniques (Tier 4)
Only if the project accepts nightly. The biggest win here is build-std:
HOST=$(rustc -vV | grep host | awk '{print $2}')
cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features="optimize_for_size" \
--target "$HOST"This recompiles libstd with your profile settings. Expect 20-50% additional reduction.
Phase 6: Post-Build and Specialist Tracks
Use only when earlier phases are exhausted:
1. UPX compression (Linux/Windows only -- not macOS) 2. Cross-language LTO for projects with C dependencies 3. Linker flags (ICF, gc-sections) -- only when LTO is disabled 4. Code-level changes (derive audit, outline pattern, feature gating)
Stop Conditions
Stop when one of these is true:
1. The next reduction would break runtime behavior or observability. 2. The remaining size is dominated by required dependencies or embedded assets. 3. The compile time increase outweighs the size benefit. 4. The binary is already small enough for the delivery mechanism.
#!/usr/bin/env bash
set -euo pipefail
section() {
printf '== %s ==\n' "$1"
}
section "rustc-version"
rustc --version
cargo --version
section "active-toolchain"
rustup show active-toolchain 2>/dev/null || echo "no rustup"
section "host-triple"
rustc -vV | grep host | awk '{print $2}'
section "cargo-toml"
if [[ -f Cargo.toml ]]; then
printf 'found=true\n'
# Extract profile.release if present
if grep -q '\[profile\.release\]' Cargo.toml; then
printf '\n[profile.release] settings:\n'
sed -n '/\[profile\.release\]/,/^\[/p' Cargo.toml | head -20
else
printf 'profile.release=absent\n'
fi
else
printf 'found=false\n'
fi
section "cargo-config"
if [[ -f .cargo/config.toml ]]; then
printf 'found=true\n'
cat .cargo/config.toml
elif [[ -f .cargo/config ]]; then
printf 'found=true (legacy path)\n'
cat .cargo/config
else
printf 'found=false\n'
fi
section "workspace"
if grep -q '\[workspace\]' Cargo.toml 2>/dev/null; then
printf 'workspace=true\n'
grep -A 20 '\[workspace\]' Cargo.toml | grep -E '^\s*"' | head -20 || true
else
printf 'workspace=false\n'
fi
section "binary-targets"
# Find all binary targets
find . -name Cargo.toml -not -path '*/target/*' -exec grep -l '\[\[bin\]\]\|name.*=.*"' {} \; 2>/dev/null | head -10
find . -name main.rs -not -path '*/target/*' -not -path '*/example*' 2>/dev/null | head -10
section "dependency-count"
if [[ -f Cargo.lock ]]; then
count=$(grep -c '^name = ' Cargo.lock 2>/dev/null || echo "unknown")
printf 'crates=%s\n' "$count"
else
printf 'no Cargo.lock\n'
fi
section "duplicate-deps"
cargo tree --duplicates 2>&1 | head -30 || true
section "heavy-crates"
for crate in reqwest tokio hyper serde clap regex chrono aws-lc-sys ring; do
if grep -q "\"$crate\"" Cargo.lock 2>/dev/null; then
printf '%s=present\n' "$crate"
fi
done
section "rust-toolchain"
if [[ -f rust-toolchain.toml ]]; then
cat rust-toolchain.toml
elif [[ -f rust-toolchain ]]; then
cat rust-toolchain
else
printf 'not pinned\n'
fi
#!/usr/bin/env bash
set -euo pipefail
if (($# != 2)); then
printf 'usage: compare-size-report.sh <before-artifact> <after-artifact>\n' >&2
exit 64
fi
before="$1"
after="$2"
for artifact in "$before" "$after"; do
if [[ ! -f "$artifact" ]]; then
printf 'artifact not found: %s\n' "$artifact" >&2
exit 66
fi
done
file_bytes() {
if [[ "$(uname -s)" == "Darwin" ]]; then
stat -f '%z' "$1"
else
stat -c '%s' "$1"
fi
}
gzip_bytes() {
gzip -n -9 -c "$1" | wc -c | tr -d ' '
}
xz_bytes() {
if command -v xz >/dev/null 2>&1; then
xz -9e -c "$1" | wc -c | tr -d ' '
else
printf '0\n'
fi
}
delta_pct() {
awk -v before="$1" -v after="$2" 'BEGIN {
if (before == 0) {
print "0.00"
exit
}
printf "%.2f", ((after - before) / before) * 100
}'
}
section() {
printf '== %s ==\n' "$1"
}
before_bytes="$(file_bytes "$before")"
after_bytes="$(file_bytes "$after")"
before_gzip="$(gzip_bytes "$before")"
after_gzip="$(gzip_bytes "$after")"
before_xz="$(xz_bytes "$before")"
after_xz="$(xz_bytes "$after")"
section "summary"
printf 'before=%s\n' "$before"
printf 'after=%s\n' "$after"
printf 'before_bytes=%s\n' "$before_bytes"
printf 'after_bytes=%s\n' "$after_bytes"
printf 'delta_bytes=%s\n' "$((after_bytes - before_bytes))"
printf 'delta_pct=%s\n' "$(delta_pct "$before_bytes" "$after_bytes")"
printf 'before_gzip_bytes=%s\n' "$before_gzip"
printf 'after_gzip_bytes=%s\n' "$after_gzip"
printf 'delta_gzip_bytes=%s\n' "$((after_gzip - before_gzip))"
printf 'delta_gzip_pct=%s\n' "$(delta_pct "$before_gzip" "$after_gzip")"
printf 'before_xz_bytes=%s\n' "$before_xz"
printf 'after_xz_bytes=%s\n' "$after_xz"
printf 'delta_xz_bytes=%s\n' "$((after_xz - before_xz))"
printf 'delta_xz_pct=%s\n' "$(delta_pct "$before_xz" "$after_xz")"
#!/usr/bin/env bash
set -euo pipefail
if (($# != 1)); then
printf 'usage: measure-binary-size.sh <artifact>\n' >&2
exit 64
fi
artifact="$1"
if [[ ! -f "$artifact" ]]; then
printf 'artifact not found: %s\n' "$artifact" >&2
exit 66
fi
section() {
printf '== %s ==\n' "$1"
}
file_bytes() {
if [[ "$(uname -s)" == "Darwin" ]]; then
stat -f '%z' "$1"
else
stat -c '%s' "$1"
fi
}
sha256_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
gzip_bytes() {
gzip -n -9 -c "$1" | wc -c | tr -d ' '
}
xz_bytes() {
if command -v xz >/dev/null 2>&1; then
xz -9e -c "$1" | wc -c | tr -d ' '
else
printf 'unavailable\n'
fi
}
section "summary"
printf 'path=%s\n' "$artifact"
printf 'bytes=%s\n' "$(file_bytes "$artifact")"
printf 'sha256=%s\n' "$(sha256_file "$artifact")"
printf 'gzip_bytes=%s\n' "$(gzip_bytes "$artifact")"
printf 'xz_bytes=%s\n' "$(xz_bytes "$artifact")"
section "file"
file "$artifact" 2>/dev/null || true
section "sections"
if command -v size >/dev/null 2>&1; then
size "$artifact" 2>/dev/null || true
fi
section "top-symbols"
if command -v nm >/dev/null 2>&1; then
nm --print-size --size-sort --reverse-sort "$artifact" 2>/dev/null | head -n 30 || true
fi
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
usage: reproducible-build.sh -o <artifact> [options]
Options:
-o, --output <path> output artifact path (copies from target/release/)
-p, --package <name> package to build (-p flag to cargo)
--profile-optimized apply size-optimized profile settings before building
--clean run cargo clean before building
--nightly use cargo +nightly
--build-std add -Z build-std=std,panic_abort (requires nightly)
--extra-rustflags <f> additional RUSTFLAGS
EOF
}
out=""
package=""
profile_optimized=0
clean=0
nightly=0
build_std=0
extra_rustflags=""
while (($# > 0)); do
case "$1" in
-o|--output)
out="$2"
shift 2
;;
-p|--package)
package="$2"
shift 2
;;
--profile-optimized)
profile_optimized=1
shift
;;
--clean)
clean=1
shift
;;
--nightly)
nightly=1
shift
;;
--build-std)
build_std=1
nightly=1
shift
;;
--extra-rustflags)
extra_rustflags="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
usage >&2
exit 64
;;
esac
done
if [[ -z "$out" ]]; then
printf 'missing required output path\n' >&2
usage >&2
exit 64
fi
mkdir -p "$(dirname "$out")"
if [[ "$clean" -eq 1 ]]; then
cargo clean --release 2>/dev/null || true
fi
cargo_args=(build --release)
if [[ "$nightly" -eq 1 ]]; then
cargo_cmd="cargo +nightly"
else
cargo_cmd="cargo"
fi
if [[ -n "$package" ]]; then
cargo_args+=(-p "$package")
fi
if [[ "$build_std" -eq 1 ]]; then
host=$(rustc -vV | grep host | awk '{print $2}')
cargo_args+=(-Z build-std=std,panic_abort)
cargo_args+=(-Z build-std-features="optimize_for_size")
cargo_args+=(--target "$host")
fi
env_vars=""
if [[ -n "$extra_rustflags" ]]; then
env_vars="RUSTFLAGS=\"$extra_rustflags\""
fi
{
printf 'command=%s %s' "$cargo_cmd" "$(printf '%q ' "${cargo_args[@]}")"
printf '\n'
[[ -n "$env_vars" ]] && printf '%s\n' "$env_vars"
} >&2
if [[ -n "$extra_rustflags" ]]; then
RUSTFLAGS="$extra_rustflags" $cargo_cmd "${cargo_args[@]}"
else
$cargo_cmd "${cargo_args[@]}"
fi
# Find and copy the binary
if [[ -n "$package" ]]; then
bin_name="$package"
else
# Try to detect binary name from Cargo.toml
bin_name=$(grep -A5 '\[\[bin\]\]' Cargo.toml 2>/dev/null | grep 'name' | head -1 | sed 's/.*"\(.*\)".*/\1/' || basename "$(pwd)")
fi
if [[ "$build_std" -eq 1 ]]; then
bin_path="target/$host/release/$bin_name"
else
bin_path="target/release/$bin_name"
fi
if [[ -f "$bin_path" ]]; then
cp "$bin_path" "$out"
printf 'copied %s -> %s\n' "$bin_path" "$out" >&2
else
printf 'binary not found at %s, checking target/release/\n' "$bin_path" >&2
# List what was built
find target/release -maxdepth 1 -type f -perm +111 ! -name '*.d' ! -name '*.dylib' 2>/dev/null | head -5 >&2
fi