Performance and Memory Trade-Offs
Designing systems software regularly involves trading memory footprint for lookup speed (caching, precomputed lookup tables, hash indexes) or trading slight CPU compute for drastically smaller memory footprint (bit-packing, string interning, compact integer IDs)—which in turn often makes the program faster overall because the working set fits inside the CPU's L2/L3 cache!
The Memory-Compute Feedback Loop on Modern CPUs:
Naive Uncompressed Representation (e.g., 64-byte structs + duplicated `String`s)
|
+--> Exceeds 16 MiB L3 Cache ---> Every random lookup stalls ~80ns on DRAM!
Compact Representation (e.g., 8-byte interned `SymbolId(u32)` + bit-packed flags)
|
+--> Entire working set fits in L2/L3 Cache ---> Lookups finish in ~3-10ns!
Classic Space-Time Trade-Off Techniques in Rust
| Technique | What You Trade | Benefit Gained |
|---|---|---|
| String Interning | Small once-per-unique-string dictionary | Replaces 24-byte heap Strings + $O(n)$ string comparisons with a 4-byte SymbolId(u32) that compares and hashes in $O(1)$ (1 CPU cycle)! |
Precomputed Lookup Tables (const) |
256B – 64 KiB of .rodata memory |
Replaces multi-branch loops (CRC32, hex/base64 decoding, character classes) with an $O(1)$ array index. |
Box<[T]> / Arc<str> over Vec<T> / String |
Gives up in-place .push() capacity growth |
Saves 8 bytes per instance (16B instead of 24B) and eliminates up to 50% unused geometric spare capacity! |
| Probabilistic Filters (Bloom Filter) | Small fixed bit-array (~10 bits per key) + ~1% false-positive rate | Eliminates 99% of expensive disk reads or network queries for non-existent keys in $O(1)$ time! |
Implementing a Zero-Allocation-per-Duplicate String Interner
In compilers, log analyzers, and JSON/CSV processors, millions of records repeat the same few hundred field names or status strings ("USD", "ACTIVE", "GET"). A String Interner stores each unique string once and hands out a 4-byte Copy Symbol(u32):
use std::collections::HashMap;
/// A 4-byte `Copy` handle representing an interned string.
/// Comparing two `Symbol`s (`a == b`) is a single 32-bit integer comparison!
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Symbol(u32);
#[derive(Default)]
pub struct StringInterner {
map: HashMap<Box<str>, Symbol>,
strings: Vec<Box<str>>,
}
impl StringInterner {
pub fn new() -> Self {
Self::default()
}
/// Returns the existing `Symbol` if `text` was already interned (with 0 allocations!),
/// or allocates `text` once and assigns the next `Symbol`.
pub fn intern(&mut self, text: &str) -> Symbol {
if let Some(&sym) = self.map.get(text) {
return sym;
}
let sym = Symbol(self.strings.len() as u32);
let boxed: Box<str> = text.into();
self.strings.push(boxed.clone());
self.map.insert(boxed, sym);
sym
}
pub fn resolve(&self, sym: Symbol) -> Option<&str> {
self.strings.get(sym.0 as usize).map(Box::as_ref)
}
pub fn unique_count(&self) -> usize {
self.strings.len()
}
}
fn main() {
let mut interner = StringInterner::new();
let s1 = interner.intern("application/json");
let s2 = interner.intern("text/plain");
let s3 = interner.intern("application/json"); // Duplicate: 0 new strings added!
// O(1) integer equality check instead of byte-by-byte string comparison:
assert_eq!(s1, s3);
assert_ne!(s1, s2);
assert_eq!(interner.unique_count(), 2);
println!(
"Interned 3 strings into {} unique symbols; s1 == s3: {}, resolved s1: {:?}",
interner.unique_count(),
s1 == s3,
interner.resolve(s1)
);
}
Interned 3 strings into 2 unique symbols; s1 == s3: true, resolved s1: Some("application/json")
Shrinking Excess Capacity on Long-Lived Collections (shrink_to_fit)
While geometric capacity doubling ($2\times$) makes Vec::push and HashMap::insert fast during construction, a Vec that grew to 1,025 elements has an allocated capacity of 2,048 elements—leaving nearly 50% of its heap allocation unused forever if the vector is never mutated again.
For long-lived, read-only collections retained in memory for the lifetime of a server, call vec.shrink_to_fit() (or convert into vec.into_boxed_slice()) once construction finishes to return the unused tail capacity to the allocator.