Rust has gained popularity for its performance and safety features, but optimizing binary size remains a crucial consideration for many projects. I’ve spent considerable time exploring techniques to shrink Rust executables, and I’m excited to share my findings with you.
Let’s dive into six powerful methods for reducing the size of Rust binaries:
- Link-time optimization
Link-time optimization (LTO) is a powerful technique that enables the compiler to perform optimizations across the entire program, rather than just within individual compilation units. By enabling LTO, we can often achieve significant reductions in binary size.
To enable LTO in your Rust project, add the following to your Cargo.toml file:
[profile.release]
lto = true
In my experience, LTO can reduce binary size by 10-20% in many cases. However, it’s important to note that enabling LTO may increase compilation times, so it’s best suited for release builds rather than development builds.
- Strip symbols
Debug symbols are incredibly useful during development and debugging, but they can significantly increase the size of your binary. For release builds, we can strip these symbols to reduce the file size.
After building your release binary, use the strip command to remove debug symbols:
strip target/release/your_binary_name
This simple step can often reduce binary size by 50% or more, especially for larger projects with many debug symbols.
- Panic abort
By default, Rust uses unwinding for panic handling, which requires additional code to be included in the binary. If you don’t need to catch panics, you can configure your program to abort on panic instead, which can reduce the binary size.
Add the following to your Cargo.toml:
[profile.release]
panic = "abort"
This configuration can lead to a noticeable reduction in binary size, particularly for smaller programs where the panic handling code represents a larger proportion of the total size.
- Optimize dependencies
External dependencies can contribute significantly to your binary size. It’s crucial to audit your dependencies regularly and consider alternatives or remove unnecessary ones.
Here are some strategies I’ve found effective:
- Use cargo-udeps to identify unused dependencies.
- Consider lighter alternatives for heavy dependencies.
- Use feature flags to include only necessary functionality from dependencies.
For example, if you’re using the popular serde crate for serialization, you might only need a subset of its features:
[dependencies]
serde = { version = "1.0", features = ["derive"], default-features = false }
By carefully managing dependencies, you can often achieve substantial reductions in binary size.
- Compress binaries
While not a Rust-specific technique, compressing your binary can significantly reduce its on-disk size. The Ultimate Packer for eXecutables (UPX) is a popular tool for this purpose.
To compress your Rust binary with UPX:
upx --best target/release/your_binary_name
In my projects, I’ve seen compression ratios of 60-70%, which can be a game-changer for distribution and storage. However, keep in mind that compressed binaries may have slightly longer startup times.
- Conditional compilation
Rust’s powerful conditional compilation features allow you to exclude unnecessary code based on compile-time conditions. This can be particularly useful for reducing binary size when building for different targets or feature sets.
Use feature flags in your Cargo.toml:
[features]
default = ["feature1"]
feature1 = []
feature2 = []
Then use #[cfg] attributes in your code:
#[cfg(feature = "feature1")]
fn feature1_function() {
// Implementation
}
#[cfg(feature = "feature2")]
fn feature2_function() {
// Implementation
}
By selectively including only the necessary features, you can significantly reduce the amount of code compiled into your binary.
Now, let’s dive deeper into each of these techniques with more detailed examples and personal insights.
Link-time optimization (LTO) is a powerful tool in our optimization arsenal. When I first enabled LTO on a medium-sized project, I was surprised by the 15% reduction in binary size. However, the compilation time nearly doubled. To mitigate this, I now use thin LTO for development builds:
[profile.dev]
lto = "thin"
[profile.release]
lto = true
Thin LTO provides a balance between optimization and compilation speed, making it suitable for development builds.
Stripping symbols is a straightforward yet effective technique. In one project, I reduced a 10MB binary to just 3MB by stripping symbols. However, be cautious when distributing stripped binaries, as they lack debugging information. I usually keep both stripped and unstripped versions:
cp target/release/my_binary target/release/my_binary_debug
strip target/release/my_binary
This approach allows me to distribute the smaller stripped binary while keeping the debug version for troubleshooting.
The panic abort strategy can be particularly effective for embedded systems or other environments where unwinding isn’t necessary or supported. In a recent embedded project, switching to panic abort reduced the binary size by about 8%. However, it’s crucial to thoroughly test your application after making this change, as it alters the behavior of panics.
Optimizing dependencies is an ongoing process. I regularly review my project’s dependencies and have found some interesting patterns. For instance, replacing a full-featured logging library with a simpler alternative reduced my binary size by 300KB. Here’s an example of how I might replace the log crate with a simpler custom logger:
pub fn log(level: &str, message: &str) {
println!("[{}] {}", level, message);
}
// Usage
log("INFO", "Application started");
While this basic logger lacks many features, it might be sufficient for simpler applications and can significantly reduce binary size.
Compressing binaries with UPX has been a go-to technique for me, especially for desktop applications where a smaller download size is crucial. However, I’ve noticed that UPX compression is less effective on binaries that have already been heavily optimized. In one project, UPX only achieved a 30% size reduction on an LTO-optimized binary, compared to 65% on a non-LTO binary.
Conditional compilation is a powerful technique that I use extensively in cross-platform projects. For example, in a CLI tool that supports both Unix and Windows systems, I use conditional compilation to include only the necessary code for each platform:
#[cfg(unix)]
fn get_home_dir() -> String {
std::env::var("HOME").unwrap_or_else(|_| "/".to_string())
}
#[cfg(windows)]
fn get_home_dir() -> String {
std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\".to_string())
}
This approach ensures that platform-specific code is only included when building for that platform, reducing binary size for each target.
Another technique I’ve found useful is to use the #[inline] attribute judiciously. While inlining can improve performance, it can also increase binary size if overused. I typically reserve #[inline] for very small, frequently called functions:
#[inline]
fn square(x: f64) -> f64 {
x * x
}
In addition to these techniques, I’ve also experimented with custom allocators to reduce binary size. For example, using a simple bump allocator for programs with predictable memory usage patterns can eliminate the need for a more complex general-purpose allocator:
use std::alloc::{GlobalAlloc, Layout};
use std::cell::UnsafeCell;
struct BumpAllocator {
heap: UnsafeCell<[u8; 1024 * 1024]>, // 1MB heap
next: UnsafeCell<usize>,
}
unsafe impl Sync for BumpAllocator {}
unsafe impl GlobalAlloc for BumpAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let size = layout.size();
let align = layout.align();
let heap_start = self.heap.get() as usize;
let mut offset = *self.next.get();
offset = (offset + align - 1) & !(align - 1);
if offset + size > self.heap.get().len() {
std::ptr::null_mut()
} else {
*self.next.get() = offset + size;
(heap_start + offset) as *mut u8
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// This allocator doesn't support deallocation
}
}
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
heap: UnsafeCell::new([0; 1024 * 1024]),
next: UnsafeCell::new(0),
};
While this allocator is simplistic and not suitable for all use cases, it demonstrates how custom memory management can lead to smaller binaries in certain scenarios.
When optimizing binary size, it’s crucial to measure the impact of each change. I use a simple shell script to track binary sizes across different optimization attempts:
#!/bin/bash
cargo build --release
echo "Base size: $(wc -c < target/release/my_binary)"
cargo build --release --features lto
echo "With LTO: $(wc -c < target/release/my_binary)"
cargo build --release --features lto,panic-abort
echo "With LTO and panic abort: $(wc -c < target/release/my_binary)"
strip target/release/my_binary
echo "Stripped: $(wc -c < target/release/my_binary)"
upx --best target/release/my_binary
echo "Compressed: $(wc -c < target/release/my_binary)"
This script helps me quickly assess the effectiveness of different optimization techniques and make informed decisions about which to apply.
It’s important to remember that binary size optimization is often a trade-off with other factors such as runtime performance, compile times, and development convenience. In my experience, the most effective approach is to start with the low-hanging fruit (like stripping symbols and enabling LTO) and then progressively apply more advanced techniques as needed.
I’ve found that combining these techniques can lead to impressive results. In one project, I managed to reduce a 12MB binary to just 2.5MB by applying all of these optimizations. The key was to iteratively apply each technique, measuring the impact at each step.
Remember, the effectiveness of these techniques can vary depending on your specific project. It’s always important to profile and measure the impact of optimizations in your particular use case.
As Rust continues to evolve, new optimization opportunities may arise. Staying informed about the latest compiler features and best practices is crucial for achieving the best possible binary sizes. I make it a habit to review the Rust release notes and experiment with new optimization flags and features as they become available.
In conclusion, optimizing Rust binary size is a multifaceted challenge that requires a combination of compiler settings, coding practices, and post-processing techniques. By applying these six techniques and continuously measuring their impact, you can significantly reduce the size of your Rust binaries, making them more suitable for resource-constrained environments and improving distribution efficiency.