Measuring Memory Use of a Rust Program With Heaptrack
Find where a Rust program allocates and how much it holds. Use /usr/bin/time for peak RSS, heaptrack for Linux allocation timelines, the dhat crate for portable heap profiles and a counting allocator for tests.

"The process uses too much memory" is a symptom with several causes: a large legitimate working set, a cache that never evicts, a collection that grows without bound or an allocator holding on to freed pages. Telling them apart takes two kinds of measurement. This post walks through both. Tools: Rust 1.90, heaptrack 1.5 (Linux), dhat 0.3. The commands are real; the numbers you see depend on your program and are not quoted.
Step 1: peak resident size
/usr/bin/time -v ./target/release/mytool big-input.txt # Linux
/usr/bin/time -l ./target/release/mytool big-input.txt # macOSLook at "Maximum resident set size". It is the cheapest measurement and the one to track in CI over time. A sudden jump between commits is the cue to dig. In a container, the cgroup's view of memory is what triggers an out-of-memory kill, so read it too (proc and sys metrics).
Step 2: make the profile readable
# Cargo.toml
[profile.release]
debug = 1 # line tables so stack traces have function names and linesWithout debug info the stack traces are addresses. Line tables add file size but not run time.
heaptrack on Linux
sudo apt install heaptrack heaptrack-gui
cargo build --release
heaptrack ./target/release/mytool big-input.txt
heaptrack_gui heaptrack.mytool.12345.zst # or: heaptrack_print file.zst | lessheaptrack intercepts allocator calls and records each with its backtrace. The GUI offers:
- Consumed memory over time: the live heap, where you see growth and spikes.
- Peak consumers: which call stacks held the most memory at the moment of the peak.
- Flame graph of allocations by stack.
- Allocations and temporary allocations: call sites that allocate and free quickly, a sign of avoidable churn (reducing allocations).
- Leaks: memory never freed by exit. In a program with a global cache or
lazystatic this reports intentional retention too, so read the stack.
The profile is of the allocator's view: a large Vec::with_capacity shows as a big allocation even if you never touch the pages. Resident memory and live heap can differ.
dhat: portable and in-process
[dependencies]
dhat = "0.3"
[features]
dhat-heap = []#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn main() {
#[cfg(feature = "dhat-heap")]
let _profiler = dhat::Profiler::new_heap();
let data: Vec<String> = (0..100_000).map(|i| format!("item {i}")).collect();
println!("{}", data.len());
}cargo run --release --features dhat-heap
# writes dhat-heap.json; open it in the viewer at
# https://nnethercote.github.io/dh_view/dh_view.htmlOn exit the profiler prints totals, including bytes and blocks allocated in total, at peak and at the end, and writes dhat-heap.json. The viewer sorts call sites by bytes at the peak or allocations made, which answers "what to fix first". The feature flag keeps the profiler out of normal builds. It works on Linux, macOS and Windows, which makes it the choice when heaptrack is unavailable.
Counting allocations in tests
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
struct Counting;
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Ordering::Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static A: Counting = Counting;
#[test]
fn parse_does_not_allocate_per_field() {
let before = ALLOCS.load(Ordering::Relaxed);
let _ = "a,b,c".split(',').count();
assert_eq!(ALLOCS.load(Ordering::Relaxed) - before, 0);
}A wrapper around the system allocator that increments a counter turns "this path must not allocate" into a test. Run such tests single-threaded, since other test threads allocate too, or use --test-threads=1. Hot paths in parsers and real-time code benefit most.
Reading what you find
- One stack holds most of the peak: a big collection or buffer. Ask whether it must exist at once; streaming or chunking can cap it.
- Steady growth from one call site: a cache or map without eviction. Add a bound (in-process caches).
- Huge allocation counts, small bytes: churn from small strings and vectors; reuse buffers or borrow instead (reusing buffers).
- Live heap is small but RSS is large: the allocator retains memory or the process maps files; try another allocator (allocators compared).


