Guide contents

Lesson 75 of 94

Allocators & Measuring Memory

Memory tuning in Rust starts with the layer underneath every Box, Vec, and String: the allocator. Knowing how it works, how to measure it, and when to replace it is what memory tuning means in Rust.

Topic Rust
Memory strategy Choosing a global allocator (System, mimalloc, jemalloc)
Heap limit No built-in limit: the container or OS limit, plus budgets you enforce
Running out of memory Allocation failure aborts the process; try_reserve for fallible allocation
Allocation profiling a tracking allocator, dhat, heaptrack, bytehound
Finding what holds memory allocation profiles by call stack (there's no object graph to dump)
Memory statistics allocator statistics (jemalloc stats, mimalloc MIMALLOC_SHOW_STATS)
Cheap bulk allocation arenas (bump allocators) freed all at once
Object pools pools of reusable buffers (often worth it)

This lesson uses unsafe impl GlobalAlloc for measurement helpers. The unsafe keyword is explained in Unsafe & FFI. Here it only forwards to the system allocator, so you can treat those lines as boilerplate.

Part 1: The global allocator

1. Every heap allocation is a Layout

An allocation request is a size and an alignment, described by std::alloc::Layout. Box<T> asks for Layout::new::<T>(), and a Vec<T> with capacity n asks for Layout::array::<T>(n). Zero-sized requests never reach the allocator.

Rust
use std::alloc::Layout;

#[allow(dead_code)]
struct Order {
    id: u64,
    qty: u32,
    express: bool,
}

fn main() {
    let order = Layout::new::<Order>();
    println!("Box<Order>:          size {:>3}, align {}", order.size(), order.align());

    let ids = Layout::array::<u32>(10).unwrap();
    println!("Vec<u32> of cap 10:  size {:>3}, align {}", ids.size(), ids.align());

    let bytes = Layout::from_size_align(100, 1).unwrap();
    println!("String of cap 100:   size {:>3}, align {}", bytes.size(), bytes.align());
}
Output
Box<Order>:          size  16, align 8
Vec<u32> of cap 10:  size  40, align 4
String of cap 100:   size 100, align 1

The allocator also receives the layout again on dealloc. Unlike C's free, Rust always tells the allocator how big the block was, which lets allocators skip storing a size header.

2. What allocates, and what doesn't

The default global allocator is std::alloc::System: malloc/free from the C library on Linux and macOS, HeapAlloc on Windows. A counting wrapper around it shows which operations actually reach the allocator. Many common ones don't.

Rust
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

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, Relaxed);
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

fn allocs<R>(f: impl FnOnce() -> R) -> usize {
    let before = ALLOCS.load(Relaxed);
    let r = f();
    let n = ALLOCS.load(Relaxed) - before;
    drop(r);
    n
}

fn main() {
    let rows = [
        ("Vec::new()", allocs(|| Vec::<u64>::new())),
        ("String::new()", allocs(String::new)),
        ("Box::new(())", allocs(|| Box::new(()))),
        ("[0u8; 4096] on the stack", allocs(|| [0u8; 4096])),
        ("\"literal\"", allocs(|| "literal")),
        ("Box::new(42)", allocs(|| Box::new(42))),
        ("String::from(\"x\")", allocs(|| String::from("x"))),
        ("format!(\"{}-{}\", 1, 2)", allocs(|| format!("{}-{}", 1, 2))),
        ("vec![0; 1000]", allocs(|| vec![0u8; 1000])),
        ("(0..1000).collect::<Vec<_>>()", allocs(|| (0..1000).collect::<Vec<u32>>())),
        ("push 1000 times, no capacity", allocs(|| { let mut v = Vec::new(); for i in 0..1000 { v.push(i) } v })),
    ];
    for (what, n) in rows {
        println!("{what:<32} {n}");
    }
}
Output
Vec::new()                       0
String::new()                    0
Box::new(())                     0
[0u8; 4096] on the stack         0
"literal"                        0
Box::new(42)                     1
String::from("x")                1
format!("{}-{}", 1, 2)           1
vec![0; 1000]                    1
(0..1000).collect::<Vec<_>>()    1
push 1000 times, no capacity     9

Empty collections and zero-sized types never allocate, so Vec::new() in a struct constructor is free. The last row counts the growth steps from the previous lesson: one allocation for capacity 4, then a reallocation at 8, 16, ..., 1024. This wrapper only overrides alloc and dealloc, so its default realloc calls alloc and is counted too.

3. Swapping the global allocator

The allocator is chosen once per binary with #[global_allocator]. Two drop-in replacements are common in production services:

TOMLCargo.toml
[dependencies]
mimalloc = "0.1"
# or, on Linux/macOS:
tikv-jemallocator = "0.6"
Rust
use mimalloc::MiMalloc;

#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

fn main() {
    // Every Box, Vec, String, and HashMap in the program now uses mimalloc.
    let v: Vec<u64> = (0..1_000).collect();
    println!("{}", v.len());
}
Allocator Strengths Consider it when
System (glibc malloc) no dependency, well understood default; small tools and most programs
mimalloc fast, low fragmentation, good multi-threaded scaling many threads allocating; musl builds (musl's malloc is slow)
jemalloc low fragmentation for long-running servers, rich stats and heap profiling long-lived services whose memory keeps creeping up

Choosing an allocator is the main memory-tuning knob Rust offers. Measure before and after. In allocation-heavy multi-threaded servers, a different allocator can reduce memory use or tail latency noticeably; in single-threaded tools it often makes no measurable difference.

4. A tracking allocator: current and peak bytes

Counting calls tells you how often you allocate. For memory use you want how much: bytes currently live and the peak. That's a few more atomics in the same wrapper, and gives you a poor man's heap monitor you can use in tests.

Rust
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

struct Tracking;
static CURRENT: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for Tracking {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let now = CURRENT.fetch_add(layout.size(), Relaxed) + layout.size();
        PEAK.fetch_max(now, Relaxed);
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        CURRENT.fetch_sub(layout.size(), Relaxed);
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Tracking = Tracking;

/// Runs `f` and returns its result plus the peak bytes allocated while it ran.
fn peak_of<R>(f: impl FnOnce() -> R) -> (R, usize) {
    let base = CURRENT.load(Relaxed);
    PEAK.store(base, Relaxed);
    let r = f();
    (r, PEAK.load(Relaxed) - base)
}

fn main() {
    let input: String = (0..100_000).map(|i| format!("{i}\n")).collect();

    // Version 1: collect every line into a Vec<String>, then sum.
    let (sum1, peak1) = peak_of(|| {
        let lines: Vec<String> = input.lines().map(String::from).collect();
        lines.iter().map(|l| l.parse::<u64>().unwrap()).sum::<u64>()
    });

    // Version 2: stream over borrowed lines.
    let (sum2, peak2) = peak_of(|| input.lines().map(|l| l.parse::<u64>().unwrap()).sum::<u64>());

    println!("collect then sum: sum {sum1}, peak {peak1} bytes");
    println!("stream:           sum {sum2}, peak {peak2} bytes");
}
Output
collect then sum: sum 4999950000, peak 5035167 bytes
stream:           sum 4999950000, peak 0 bytes

Same result, and the streaming version allocates nothing at all. Measuring peak bytes like this in a test is a cheap way to stop a regression that would otherwise show up as an out-of-memory kill in production.

5. Which sizes dominate? An allocation histogram

When a service allocates too much, the next question is what. A histogram of request sizes often answers it: millions of 16–32 byte allocations point to small Strings or Boxes, while a few huge ones point to big buffers.

Rust
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed};

struct Histogram;
static ENABLED: AtomicBool = AtomicBool::new(false);
// Buckets: <=16, <=64, <=256, <=4096, larger.
static BUCKETS: [AtomicUsize; 5] = [const { AtomicUsize::new(0) }; 5];

unsafe impl GlobalAlloc for Histogram {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        if ENABLED.load(Relaxed) {
            let i = match layout.size() {
                0..=16 => 0,
                17..=64 => 1,
                65..=256 => 2,
                257..=4096 => 3,
                _ => 4,
            };
            BUCKETS[i].fetch_add(1, Relaxed);
        }
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Histogram = Histogram;

struct Event {
    user: String,
    tags: Vec<String>,
}

fn main() {
    ENABLED.store(true, Relaxed);
    let events: Vec<Event> = (0..10_000)
        .map(|i| Event { user: format!("u{}", i % 500), tags: vec!["web".to_string(), "eu".to_string()] })
        .collect();
    ENABLED.store(false, Relaxed);

    for (label, bucket) in ["<=16", "<=64", "<=256", "<=4096", "larger"].iter().zip(&BUCKETS) {
        println!("{label:>7}: {}", bucket.load(Relaxed));
    }
    println!("events: {}", events.len());
}
Output
   <=16: 39800
   <=64: 10000
  <=256: 0
 <=4096: 0
 larger: 1
events: 10000

Each event makes three tiny string allocations and one 48-byte Vec buffer, which accounts for 40,000 allocations. The other 9,800 are a surprise worth knowing about: format! can't know the final length in advance, so it starts with a tiny buffer and grows it. Every user id longer than two characters was allocated twice. Almost 50,000 allocations for 10,000 events, and the fix is clear: store the 500 distinct users once and refer to them (interning, see example 9), and store tags as a small enum or bit set instead of Vec<String>.

Part 2: RSS, fragmentation, and limits

6. Freed memory isn't always returned to the OS

The number your container monitoring shows is RSS (resident set size), not "live bytes". After a spike, a Rust process often keeps a high RSS even though everything was freed. The reason is the allocator, not a leak:

  • malloc keeps freed small blocks to reuse them. glibc mostly returns memory to the OS when the top of its heap becomes free; only large blocks (over the mmap threshold, 128 KiB by default) are given back as soon as they are freed.
  • Many small allocations that stay alive, scattered among freed ones, pin the pages they sit on (fragmentation).
  • glibc spreads threads over several arenas (up to 8 per CPU core on 64-bit) to reduce lock contention. A service with many threads can hold many partly used arenas.

You can watch RSS on Linux in /proc/self/statm (the second field is resident pages; this example assumes 4 KiB pages):

Rust
use std::fs;

fn rss_mib() -> u64 {
    let statm = fs::read_to_string("/proc/self/statm").unwrap();
    let pages: u64 = statm.split_whitespace().nth(1).unwrap().parse().unwrap();
    pages * 4096 / (1024 * 1024)
}

fn main() {
    println!("start:            {} MiB", rss_mib());
    let mut keep = Vec::new();
    let mut spike: Vec<Vec<u8>> = Vec::new();
    for i in 0..200_000 {
        spike.push(vec![1u8; 512]);
        if i % 100 == 0 {
            keep.push(vec![2u8; 512]); // a few long-lived blocks among the spike
        }
    }
    println!("during spike:     {} MiB", rss_mib());
    drop(spike);
    println!("after the spike:  {} MiB (live data is only ~1 MiB)", rss_mib());
    println!("kept {} blocks", keep.len());
}
Symptom Likely cause What helps
RSS stays high after a load spike, live bytes are low allocator keeps freed pages jemalloc or mimalloc; MALLOC_TRIM_THRESHOLD_; this is often fine
RSS grows slowly over days, live bytes are flat fragmentation jemalloc or mimalloc; fewer long-lived small allocations; arenas per request
RSS grows with thread count per-thread malloc arenas MALLOC_ARENA_MAX=2 (glibc), or mimalloc/jemalloc
live bytes grow steadily a real leak (example 13 of the previous lesson) find the growing collection or cycle

This is why "the Rust service uses 2 GB" needs a second number before you act: live bytes from the allocator, or a profiler.

7. There's no built-in heap limit: allocation failure aborts

A Rust process has no heap limit of its own. It grows until the OS or container refuses, and then:

  • If malloc returns null, Rust prints memory allocation of N bytes failed and aborts. There's no exception to catch.
  • More often on Linux, the kernel's OOM killer ends the process with SIGKILL when the container reaches its cgroup limit, with no message at all.

For requests with untrusted sizes, use the fallible APIs. try_reserve returns an error instead of aborting.

Rust
fn read_payload(declared_len: usize) -> Result<Vec<u8>, String> {
    let mut buf = Vec::new();
    buf.try_reserve_exact(declared_len).map_err(|e| format!("cannot allocate {declared_len} bytes: {e}"))?;
    buf.resize(declared_len, 0); // stands in for reading from the socket
    Ok(buf)
}

fn main() {
    match read_payload(1024) {
        Ok(buf) => println!("ok: {} bytes", buf.len()),
        Err(e) => println!("error: {e}"),
    }
    match read_payload(usize::MAX / 2) {
        Ok(buf) => println!("ok: {} bytes", buf.len()),
        Err(e) => println!("error: {e}"),
    }
}
Output
ok: 1024 bytes
error: cannot allocate 9223372036854775807 bytes: memory allocation failed because the memory allocator returned an error

try_reserve only protects the one allocation. A length field from the network should also be checked against a sane maximum before you even try, because an allocation that succeeds can still push the container over its limit.

8. A memory budget

Since nothing limits the heap for you, services that handle large requests (uploads, batch jobs, image processing) enforce a budget: each request reserves the memory it will need before starting, and is rejected or queued when the budget is exhausted. It's back-pressure measured in bytes.

Rust
use std::sync::atomic::{AtomicUsize, Ordering};

struct MemoryBudget {
    limit: usize,
    used: AtomicUsize,
}

struct Reservation<'a> {
    budget: &'a MemoryBudget,
    bytes: usize,
}

impl MemoryBudget {
    const fn new(limit: usize) -> Self {
        Self { limit, used: AtomicUsize::new(0) }
    }

    fn try_reserve(&self, bytes: usize) -> Option<Reservation<'_>> {
        self.used
            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| used.checked_add(bytes).filter(|&total| total <= self.limit))
            .ok()
            .map(|_| Reservation { budget: self, bytes })
    }
}

impl Drop for Reservation<'_> {
    fn drop(&mut self) {
        self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel); // released when the request finishes
    }
}

static BUDGET: MemoryBudget = MemoryBudget::new(100 * 1024 * 1024);

fn start_upload(name: &str, size: usize) -> Option<Reservation<'static>> {
    let r = BUDGET.try_reserve(size);
    let used = BUDGET.used.load(Ordering::Acquire) / (1024 * 1024);
    println!("{name:<8} {:>3} MiB: {} (in use: {used} MiB)", size / (1024 * 1024), if r.is_some() { "accepted" } else { "rejected, try later" });
    r
}

fn main() {
    let a = start_upload("video-a", 60 * 1024 * 1024);
    let _b = start_upload("video-b", 30 * 1024 * 1024);
    let _c = start_upload("video-c", 20 * 1024 * 1024);
    drop(a); // video-a finished
    let _d = start_upload("video-d", 20 * 1024 * 1024);
}
Output
video-a   60 MiB: accepted (in use: 60 MiB)
video-b   30 MiB: accepted (in use: 90 MiB)
video-c   20 MiB: rejected, try later (in use: 90 MiB)
video-d   20 MiB: accepted (in use: 50 MiB)

The reservation is an RAII guard, so the bytes come back even if the request fails or panics. In async code, tokio::sync::Semaphore with one permit per KiB or MiB does the same and lets callers wait instead of failing. Set the budget well below the container limit to leave room for everything else the process allocates.

Part 3: Allocating less

9. Arenas: many objects, one free

An arena makes allocation a pointer bump and makes dead objects cost nothing to free. It suits data with a shared lifetime: allocate into one big buffer, free the whole buffer at once. Here a string arena stores every string in one String and hands out small handles.

Rust
use std::collections::HashMap;
use std::hash::{BuildHasher, RandomState};
use std::ops::Range;

/// Stores many strings in one buffer. Handles are indices, not pointers.
#[derive(Default)]
struct StrArena {
    bytes: String,                  // every string's bytes, back to back
    spans: Vec<Range<usize>>,       // handle -> position in `bytes`
    lookup: HashMap<u64, Vec<u32>>, // content hash -> handles, for interning
    hasher: RandomState,
}

impl StrArena {
    fn intern(&mut self, s: &str) -> u32 {
        let h = self.hasher.hash_one(s);
        if let Some(ids) = self.lookup.get(&h) {
            if let Some(&id) = ids.iter().find(|&&id| self.get(id) == s) {
                return id; // already stored: no allocation at all
            }
        }
        let start = self.bytes.len();
        self.bytes.push_str(s);
        self.spans.push(start..self.bytes.len());
        let id = (self.spans.len() - 1) as u32;
        self.lookup.entry(h).or_default().push(id);
        id
    }

    fn get(&self, id: u32) -> &str {
        &self.bytes[self.spans[id as usize].clone()]
    }
}

fn main() {
    let mut arena = StrArena::default();
    let mut name = String::new(); // one reused scratch buffer instead of format! per event
    let events: Vec<u32> = (0..10_000)
        .map(|i| {
            use std::fmt::Write;
            name.clear();
            write!(name, "u{}", i % 500).unwrap();
            arena.intern(&name)
        })
        .collect();

    println!("events: {}, distinct users stored: {}", events.len(), arena.spans.len());
    println!("all user bytes live in one buffer of {} bytes", arena.bytes.len());
    println!("event 1234 is {}", arena.get(events[1234]));
}
Output
events: 10000, distinct users stored: 500
all user bytes live in one buffer of 1890 bytes
event 1234 is u234

Compared with example 5, the 10,000 user strings became one growing buffer, and each event stores a 4-byte id instead of a 24-byte String. The lookup table still makes a small allocation per distinct user (500 here, not 10,000); production interners such as lasso avoid even that. Dropping the arena frees everything in a few hundred calls instead of ten thousand. For arenas of arbitrary types, use the bumpalo crate (bump.alloc(value) returns &mut T that lives as long as the arena) or typed-arena. Compilers, parsers, and per-request processing are the classic users.

10. Object pools: reuse buffers instead of allocating

Reusing large buffers is often worthwhile: a pooled Vec<u8> keeps its capacity, so the second request that uses it doesn't allocate at all. A guard returns the buffer to the pool in Drop.

Rust
use std::ops::{Deref, DerefMut};
use std::sync::Mutex;

struct BufferPool {
    free: Mutex<Vec<Vec<u8>>>,
}

struct PooledBuf<'a> {
    pool: &'a BufferPool,
    buf: Vec<u8>,
}

impl BufferPool {
    fn get(&self) -> PooledBuf<'_> {
        let buf = self.free.lock().unwrap().pop().unwrap_or_else(|| Vec::with_capacity(64 * 1024));
        PooledBuf { pool: self, buf }
    }
    fn idle(&self) -> usize {
        self.free.lock().unwrap().len()
    }
}

impl Drop for PooledBuf<'_> {
    fn drop(&mut self) {
        let mut buf = std::mem::take(&mut self.buf);
        buf.clear(); // keep the capacity, forget the contents
        self.pool.free.lock().unwrap().push(buf);
    }
}

impl Deref for PooledBuf<'_> {
    type Target = Vec<u8>;
    fn deref(&self) -> &Vec<u8> {
        &self.buf
    }
}

impl DerefMut for PooledBuf<'_> {
    fn deref_mut(&mut self) -> &mut Vec<u8> {
        &mut self.buf
    }
}

fn handle(pool: &BufferPool, request: &str) -> usize {
    let mut buf = pool.get();
    buf.extend_from_slice(b"HTTP/1.1 200 OK\r\n\r\n");
    buf.extend_from_slice(request.to_uppercase().as_bytes());
    buf.len()
}

fn main() {
    let pool = BufferPool { free: Mutex::new(Vec::new()) };
    for req in ["hello", "pooled", "buffers"] {
        let len = handle(&pool, req);
        let first_ptr = pool.get().as_ptr() as usize; // borrow one to see which buffer comes back
        println!("{req}: {len} bytes, idle buffers {}, reused same buffer: {}", pool.idle(), first_ptr == pool.get().as_ptr() as usize);
    }
}
Output
hello: 24 bytes, idle buffers 1, reused same buffer: true
pooled: 25 bytes, idle buffers 1, reused same buffer: true
buffers: 26 bytes, idle buffers 1, reused same buffer: true

Keep pools bounded (drop a buffer instead of pushing it when the pool is full, or when its capacity grew huge), or the pool itself becomes the leak. Crates such as object-pool and bytes::BytesMut (whose split and reserve reuse memory) cover the common cases.

11. Formatting without allocating

format! and to_string() return a new String, which allocates for any non-empty result. When the result only needs to be written somewhere, write into a stack buffer or directly into the destination instead.

Rust
use std::alloc::{GlobalAlloc, Layout, System};
use std::fmt::Write as _;
use std::io::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

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, Relaxed);
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

fn main() {
    let (user, latency_ms) = ("ana", 42);

    // 1. format!: a new String per call.
    let before = ALLOCS.load(Relaxed);
    let keys: Vec<usize> = (0..1000).map(|i| format!("{user}:{latency_ms}:{i}").len()).collect();
    println!("format!:          {} allocations", ALLOCS.load(Relaxed) - before - 1);

    // 2. One String reused with clear() + write!.
    let before = ALLOCS.load(Relaxed);
    let mut line = String::with_capacity(64);
    for i in 0..1000 {
        line.clear();
        write!(line, "{user}:{latency_ms}:{i}").unwrap();
    }
    println!("reused String:    {} allocation", ALLOCS.load(Relaxed) - before);

    // 3. A fixed stack buffer: no heap at all.
    let before = ALLOCS.load(Relaxed);
    let mut total = 0;
    for i in 0..1000 {
        let mut stack = [0u8; 64];
        let mut cursor = &mut stack[..];
        write!(cursor, "{user}:{latency_ms}:{i}").unwrap();
        total += 64 - cursor.len();
    }
    println!("stack buffer:     {} allocations", ALLOCS.load(Relaxed) - before);
    println!("same lengths: {}", total == keys.iter().sum::<usize>());
}
Output
format!:          1990 allocations
reused String:    1 allocation
stack buffer:     0 allocations
same lengths: true

format! makes about two allocations per call here, because it grows its buffer as it writes. The - 1 in the first measurement excludes the Vec that collects the lengths. &mut [u8] implements io::Write: each write fills the slice from the front and shrinks it, so 64 - cursor.len() is the number of bytes written. The same idea applies to write! directly into a BufWriter, a socket, or a response body: never build a String just to copy it somewhere else.

12. Small values inline: SmallVec, ArrayVec, CompactString

Many collections are almost always small: the tags on an event, the path segments of a URL, the arguments of a call. Several crates store the first few elements inline, and only allocate when they overflow:

Crate Type Behavior
smallvec SmallVec<[T; N]> up to N elements inline, then spills to the heap
arrayvec ArrayVec<T, N>, ArrayString<N> fixed capacity N, never allocates, push fails when full
compact_str CompactString strings up to 24 bytes inline, same size as String
std [T; N], Option<[T; N]> fixed size, no crate needed
Rust
use smallvec::{SmallVec, smallvec};

struct Event {
    tags: SmallVec<[&'static str; 4]>, // no allocation for up to 4 tags
}

fn main() {
    let e = Event { tags: smallvec!["web", "eu"] };
    println!("{} tags, spilled to heap: {}", e.tags.len(), e.tags.spilled());
}

Measure first: inline storage makes the type larger, which costs when it's moved or stored in large numbers.

Part 4: Profiling memory

13. Heap profiling with dhat

The tracking allocator answers "how much". A heap profiler answers "allocated where": it records a stack trace per allocation site. dhat is a pure-Rust profiler that works in tests and on every platform.

TOMLCargo.toml
[dependencies]
dhat = "0.3"

[features]
dhat-heap = []

[profile.release]
debug = 1 # keep symbols so stack traces are readable
Rust
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

fn main() {
    #[cfg(feature = "dhat-heap")]
    let _profiler = dhat::Profiler::new_heap(); // writes dhat-heap.json when dropped

    run_the_service();
}
Terminal
cargo run --release --features dhat-heap
# open dhat-heap.json in https://nnethercote.github.io/dh_view/dh_view.html

The viewer sorts allocation sites by total bytes, peak bytes, or number of calls, which answers most "why is it using so much memory" questions. dhat::HeapStats::get() also exposes the totals, so a test can assert that a function allocates at most N times.

14. Tools for measuring memory

Question Rust tool
Where do allocations come from? dhat, heaptrack, bytehound
How much is live right now? tracking allocator, jemalloc stats.allocated, heaptrack
What is holding memory? allocation sites of live blocks (heaptrack, dhat's "at t-gmax" view)
Why is RSS higher than the heap? compare live bytes with RSS; allocator stats
Is there a leak? heaptrack "leaked" view, Valgrind --leak-check, LeakSanitizer
Does this function allocate? a counting allocator in a test, dhat::HeapStats
Memory safety bugs Miri, AddressSanitizer (see Aliasing & Undefined Behavior)

There's no heap dump with an object graph, because there are no object headers or type information in memory. In practice allocation sites are more useful anyway: they point at the line of code to change.

15. Realistic example: an allocation regression test

A test that fails when a hot function starts allocating catches performance regressions in review instead of in production. With a counting allocator installed only in the test binary, it's a few lines.

Rustcargo test
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;

// Count per thread, so tests running in parallel don't disturb each other.
thread_local!(static ALLOCS: Cell<usize> = const { Cell::new(0) });

struct Counting;

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        ALLOCS.with(|c| c.set(c.get() + 1));
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

fn allocations_in(f: impl FnOnce()) -> usize {
    let before = ALLOCS.with(Cell::get);
    f();
    ALLOCS.with(Cell::get) - before
}

/// Parses "key=value;key=value" and sums the values of keys starting with `prefix`.
pub fn sum_matching(input: &str, prefix: &str) -> u64 {
    input
        .split(';')
        .filter_map(|pair| pair.split_once('='))
        .filter(|(k, _)| k.starts_with(prefix))
        .filter_map(|(_, v)| v.parse::<u64>().ok())
        .sum()
}

#[test]
fn sum_matching_is_correct() {
    assert_eq!(sum_matching("cpu.user=3;cpu.sys=4;mem=100", "cpu."), 7);
}

#[test]
fn sum_matching_does_not_allocate() {
    let input = "cpu.user=3;cpu.sys=4;mem=100;".repeat(100);
    let n = allocations_in(|| {
        std::hint::black_box(sum_matching(&input, "cpu."));
    });
    assert_eq!(n, 0, "sum_matching allocated {n} times");
}
Output
running 2 tests
test sum_matching_does_not_allocate ... ok
test sum_matching_is_correct ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

If someone later changes k.starts_with(prefix) to k.to_string().starts_with(prefix), the second test fails with the number of allocations it introduced.

Summary

  • Every Box, Vec, and String goes through the global allocator (System by default). #[global_allocator] swaps it for mimalloc or jemalloc.
  • A wrapper allocator with a few atomics counts allocations, live bytes, and peak bytes: use it in tests to stop regressions.
  • RSS is not live bytes. Allocators keep freed memory, fragment, and use per-thread arenas; compare both numbers before calling something a leak.
  • There's no built-in heap limit. Allocation failure aborts or the OOM killer strikes, so validate untrusted sizes, use try_reserve, and enforce memory budgets for large work.
  • Allocate less with arenas and interning, pooled buffers, write! into reused or stack buffers, and inline small collections.
  • Profile with dhat, heaptrack, or bytehound to find allocation sites; there's no object graph to dump, and you rarely miss it.

Next: The Rust Memory Model: happens-before, atomic orderings, fences, and false sharing.