Rust Performance Engineering

CPU and Memory Profiling

CPU profiling and memory (heap) profiling answer two orthogonal performance questions: CPU profiling identifies where the processor spends execution cycles and where hardware pipeline stalls occur, while memory profiling identifies which call stacks allocate the most heap blocks, cause peak RAM usage (high-water mark), or churn short-lived temporary allocations.

+-------------------------------------------------------------------+
|                   Two Dimensions of Profiling                     |
|                                                                   |
|  1. CPU Profiling (`samply`, `perf`, `cargo-flamegraph`)          |
|     - Samples instruction pointer (IP) & call stack at ~1000-4000Hz|
|     - Hardware PMU counters: IPC, Branch Misses, L1/LLC Misses    |
|                                                                   |
|  2. Heap / Allocation Profiling (`dhat`, `heaptrack`, `bytehound`)|
|     - Intercepts `GlobalAlloc::alloc` / `dealloc`                 |
|     - Measures: Total Allocations, Peak Bytes Live, & Churn       |
+-------------------------------------------------------------------+

1. CPU Profiling Commands Across Platforms

Platform Tool & Command Highlights
macOS & Linux samply record ./target/profiling/app Opens an interactive Firefox Profiler UI showing per-thread flamegraphs, call trees, and source line annotations out of the box.
Linux / macOS cargo flamegraph --bin app Generates a standalone flamegraph.svg directly from a Cargo target.
Linux Hardware PMU perf stat -d ./target/profiling/app Reports exact hardware counters: CPU cycles, instructions (IPC), branch misses, and L1-dcache / LLC load misses.

2. Heap Profiling in Rust with dhat (dhat-rs) or heaptrack

Standard CPU sampling profilers only show time spent inside malloc/free—they do not show which data structures hold the most megabytes at peak memory usage or which functions allocate 1,000,000 tiny 16-byte Vec buffers that immediately drop.

Rust provides two ways to profile heap allocations:

  1. External OS heap profilers (heaptrack on Linux, Instruments Allocations on macOS): Wrap the binary without code changes (heaptrack ./target/profiling/app).
  2. In-process Rust heap profiling (dhat crate / custom #[global_allocator]): Replaces the global allocator during a profiling run or test to record exact allocation counts, total bytes allocated, and peak live heap bytes!
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};

/// A custom profiling allocator that tracks total allocation count,
/// currently live bytes, and the peak (high-water mark) heap usage!
pub struct PeakTrackingAllocator;

static ALLOC_COUNT: AtomicUsize = AtomicUsize::new(0);
static LIVE_BYTES: AtomicUsize = AtomicUsize::new(0);
static PEAK_BYTES: AtomicUsize = AtomicUsize::new(0);

#[derive(Debug, Clone, Copy)]
pub struct HeapMetrics {
    pub alloc_count: usize,
    pub live_bytes: usize,
    pub peak_bytes: usize,
}

pub fn snapshot_heap_metrics() -> HeapMetrics {
    HeapMetrics {
        alloc_count: ALLOC_COUNT.load(Ordering::Relaxed),
        live_bytes: LIVE_BYTES.load(Ordering::Relaxed),
        peak_bytes: PEAK_BYTES.load(Ordering::Relaxed),
    }
}

unsafe impl GlobalAlloc for PeakTrackingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let ptr = unsafe { System.alloc(layout) };
        if !ptr.is_null() {
            ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
            let prev = LIVE_BYTES.fetch_add(layout.size(), Ordering::Relaxed);
            let current = prev + layout.size();
            PEAK_BYTES.fetch_max(current, Ordering::Relaxed);
        }
        ptr
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
        unsafe { System.dealloc(ptr, layout) };
    }
}

#[global_allocator]
static GLOBAL: PeakTrackingAllocator = PeakTrackingAllocator;

fn main() {
    let baseline = snapshot_heap_metrics();

    {
        // Allocate a temporary 64 KiB buffer and drop it at the end of this block
        let temp_buffer = vec![0u8; 64 * 1024];
        std::hint::black_box(&temp_buffer);
        let mid = snapshot_heap_metrics();
        assert!(mid.live_bytes >= baseline.live_bytes + 64 * 1024);
    }

    let after = snapshot_heap_metrics();
    println!(
        "Allocations during scope: {}, peak increase >= 64 KiB: {}, live restored: {}",
        after.alloc_count - baseline.alloc_count,
        after.peak_bytes >= baseline.peak_bytes + 64 * 1024,
        after.live_bytes == baseline.live_bytes
    );
}
Allocations during scope: 1, peak increase >= 64 KiB: true, live restored: true

Alternative High-Performance Global Allocators (mimalloc and jemalloc)

For multi-threaded server workloads that perform heavy concurrent allocation and deallocation across threads, replacing the default OS system allocator (std::alloc::System) with mimalloc (#[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;) or tikv-jemallocator often reduces allocator lock contention and improves multi-threaded throughput by 5%–25%.