Sampling a Slow Path with cargo flamegraph: Symbols, Release, and Overhead
A Benchmarks with Criterion run reports a 40% regression on report/aggregate, and the diff touches twelve files. You need the one function where the extra time goes — and a stack of numbers will not tell you. A flamegraph will, in one picture.
Get symbols into the binary first. Profiling a stripped release binary produces frames named unknown. Enable debug info without giving up optimization:
# Cargo.toml
[profile.release]
debug = 1 # line tables; enough for good frames, small size cost
Then record. The flamegraph crate wraps perf on Linux and dtrace on macOS:
cargo install flamegraph
cargo flamegraph --release --bin server # runs the binary, records samples
cargo flamegraph --release --test orders # a single test
cargo flamegraph --release --bench parse # a criterion benchmark
It prints flamegraph.svg in the working directory — an interactive document where bar width is share of samples, and hovering a frame tells you its function, its own percentage, and its cumulative percentage. The hottest path is the widest stack of frames from the bottom up. On Windows, run the same tools under WSL, where perf is available.
Flags worth knowing before you need them:
cargo flamegraph --release --bin server -- --port 9000 # args after --
cargo flamegraph --non-interactive --title "report/aggregate" -o agg.svg
cargo flamegraph --deterministic # stable colors run to run
cargo flamegraph --flamechart # x-axis becomes sample order
--deterministic matters the moment you keep two SVGs side by side for review; --flamechart trades the nested layout for a time-ordered one, which is how you spot "phase 1 then phase 2" behavior in a batch job.
Without the wrapper, the underlying command is worth knowing:
perf record --call-graph dwarf -F 99 -- ./target/release/server
perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg
The flags each buy something:
| Flag | Why it is there |
|---|---|
-F 99 |
99 samples/sec; not a multiple of 100, so it does not sync with timer ticks |
--call-graph dwarf |
stack traces from debug info — works without frame pointers |
perf report --stdio |
text top-down view when you want a sortable list instead of a picture |
One Rust-specific detail: release builds do not guarantee frame pointers, so if you skip --call-graph dwarf, add RUSTFLAGS="-C force-frame-pointers=yes" and use -g; otherwise stacks unwind as a single tall frame and the graph says nothing.
Each rectangle in the picture comes from one sample: the profiler interrupted the process, walked the stack, and recorded where execution was. Nothing is instrumented, so overhead scales with the sampling rate — at 99 Hz the process loses well under a percent of its time to the profiler, which is why you can record production-shaped load without the timings shifting. It also means the graph is statistical: a function taking 2% of total time earns roughly two samples in a hundred, so anything under a few percent is noise. Trust plateaus; ignore slivers.
Profile the right build. This is the rule people break first: profile release, always. An unoptimized debug build spends its time inside core/alloc helpers that do not exist in production, so you will optimize a bottleneck your users never hit. Keep debug = 1 so you keep symbols — optimization plus symbols is exactly the combination profilers want.
For a quick iteration loop, run the workload under load while recording — a benchmark harness, a wrk/hey loop against a local server, or a single test through cargo flamegraph --test. Thirty seconds of samples at 99 Hz is roughly three thousand stacks; that is plenty to separate a 60% hotspot from noise.
Reading the picture — what width, depth, and color actually mean, and how perf report cross-checks it — is the next chapter.
Reading the Flamegraph: Width Is Samples, the X-Axis Is Not Time
The SVG opens and you are staring at hundreds of colored rectangles. Two minutes of careful reading beats an afternoon of guessing, but only if you know what the axes actually claim.
The axes are not what people assume. The y-axis is stack depth: each rectangle is one function, stacked on its caller, rooted at the bottom. The x-axis is not time — frames are laid out in an arbitrary (roughly alphabetical-by-stack) order purely so rectangles nest without overlap. Nothing scrolls left to right as the program runs. The only quantity that encodes data is width: a frame's share of all samples.
┌─────────────┐
┌───────────────┤ format_row │ ← wide AND deep: hot
┌───────┤ serialize ┌──────┤ clone ─┐
│ render└───────────────┤ pretty_print │ │ to_string │
┴───────────────────────┴─────────────┴─────────┴────────────────┴
root narrow: rare path
Read it bottom-up along the widest column. Three patterns cover most real profiles:
| Pattern in the graph | What it means |
|---|---|
| Wide plateau low in the tree | a hot recursive or shared callee — time is going there |
| Wide frame at the very top | self time: the work is in this function, not its children |
| Tall thin column | deep call chain with little time — usually irrelevant |
| Repeated identical columns | one path reached from many callers; look at the widest instance |
Do not read meaning into color by default. Palettes either assign warm hues by frame heat or hash the function name for distinguishability; neither tells you anything width does not already. The exception is a differential flamegraph, generated by comparing two recordings, where color encodes the change — red hotter, blue cooler — and that is the fastest way to see what a commit did to a profile.
Cross-check with the text view. The same data without picture-reading:
perf report --stdio --sort symbol,dso # ranked list of symbols
perf report --stdio --children # with inherited (cumulative) time
perf diff old.data new.data # weight change between recordings
perf report is what you use when a frame's name in the graph is truncated or mangled. Speaking of which: if symbols come out as _ZN7mycrate…, pipe the symbolized output through rustfilt — and remember from the setup chapter that debug = 1 in the release profile is what makes any of these names exist at all.
The rendered SVG is searchable: type into the search box at the top and matching frames highlight while the rest dim, which turns "find where parse_query is called from" into a two-second lookup across thousands of frames. Double-click a frame to zoom into that subtree — useful when one hot root dominates and you need to see what it calls.
Async code reads differently. Your async fn compiles to a state machine, so the sampled stack shows poll frames rather than the logical await chain; the hot frame is typically mycrate::handler::{{closure}}::poll plus whatever it called synchronously inside. Read the innermost frames and ignore the executor noise (tokio::runtime, scheduler). And a CPU flamegraph only samples running threads — a request that spends 500 ms waiting on a database shows almost no samples at all. For that shape of problem you want wall-clock sampling or distributed tracing; for "the CPU is pegged," the flamegraph is exact.
Interview framing: "where is the time going?" answered with width-not-time, self versus cumulative, and one concrete next command (perf report or a re-record with debug = 1) is a complete, senior answer. When the profile instead points at the allocator, the next chapter takes over.
When the Flame Points at malloc: Attributing Allocations to Call Sites
The flamegraph's widest column is alloc::alloc::alloc and its callers are alloc::raw_vec::finish_grow. CPU sampling has done its job — it proved the machine is spending its time allocating — but it cannot say which line of your code asked for the bytes, nor how many are live at once. That needs a heap profiler.
The distinction matters because there are two different questions:
| Question | Tool shape |
|---|---|
| Who is spending time allocating? | CPU flamegraph (previous chapters) |
| Who holds the bytes, and how many? | heap profiler with allocation backtraces |
| Is it a leak, or a spike? | peak (t-gmax) versus end-of-run totals |
heaptrack is the practical default on Linux. It intercepts the allocator and records every allocation with a backtrace:
heaptrack ./target/release/server --once
heaptrack_print server.heaptrack.gz | less
heaptrack_gui server.heaptrack.gz # charts + per-call-site table
heaptrack_print answers the questions directly: top memory consumers ranked by call site, temporary allocations (bytes allocated and freed immediately — often 60–80% of all allocations in a parser), peak consumption, and leaked allocations reported at exit. A Vec<String> rebuilt on every loop iteration shows up as one call site owning hundreds of megabytes of churn, which no amount of code reading would surface.
dhat is the lightweight, in-process alternative — a crate, not a system tool, so it works anywhere including Windows:
fn main() {
if std::env::var("DHAT").is_ok() {
let _profiler = dhat::Profiler::new_builder()
.file_name("dhat.json")
.build();
}
run();
}
DHAT=1 cargo run --release # writes dhat.json when the process exits
Open the JSON in the dhat web UI and you get total blocks and bytes, the state at t-gmax (the moment of peak memory), and the state at the end of the run, each broken down by allocation backtrace. That pair is the diagnostic: heavy at t-gmax but clean at the end is a transient spike — normal for batch work; heavy at the end is a leak, and the backtrace names the owner.
Rust makes leaks harder, not impossible: Rc<RefCell<…>> cycles, tasks that are spawned and forgotten, and mem::forget (a safe function) all retain memory the type system will not reclaim. So the honest interview answer for "how do you find a memory leak in a Rust service?" is three steps: profile RSS over time to confirm growth, take a heap profile at peak and at exit to attribute bytes to call sites, then fix the owner — usually an unbounded cache or a dropped-signal channel.
For a long-running server you cannot pause, the allocator itself can be wired in: build with tikv-jemalloc-prof (prof:true), trigger a dump from an admin endpoint or signal, and parse the heap profile with jemalloc_pprof — same backtrace-attribution model, no process restart, which is how you catch a leak that only appears after six hours.
Practical habits that keep you out of these tools:
- Prefer
iterreuse over per-iterationto_owned()in hot loops;heaptrack's temporary-allocation percentage is the scoreboard. - Watch for
cloneframes under your own code — aStringcloned inside a formatting path is the single most common self-inflicted allocation. - Measure after an optimization with the same tool; allocation counts are only meaningful against a saved baseline, exactly like the timing numbers from Benchmarks with Criterion.
With tests, benchmarks, and profiles in hand, the last step in this phase is packaging those checks into the release itself: Production Crate Baseline.
Quiz
1. Your flamegraph shows one enormous root frame named `unknown` with no children. What is missing?
2. In a flamegraph, what does the x-axis represent?
3. perf report shows a hot frame whose name is mangled (_ZN7mycrate...). Which pair of fixes addresses it?
4. heaptrack reports huge temporary allocations and near-zero end-of-run usage. What is the correct interpretation?
Flashcards
Question
What must be configured before a Rust release binary can be profiled meaningfully, and why release?
Click to reveal answer
Answer
debug = 1 in [profile.release] so symbols exist while optimization stays on, and stack-walk data (--call-graph dwarf, or force-frame-pointers with -g). Profile release because debug builds spend their time in unoptimized core/alloc code that does not exist in production, so the hot spots you find would be artifacts.
Question
How do you read a flamegraph's axes and width?
Click to reveal answer
Answer
y-axis = stack depth, each frame stacked on its caller with the root at the bottom; x-axis = arbitrary nesting order, not time; width = share of samples. The widest column bottom-up is the hot path; a wide frame at the top is self time. Colors are decorative unless the graph is a differential comparison.
Question
Which perf commands complement cargo flamegraph, and what does -F 99 achieve?
Click to reveal answer
Answer
perf record --call-graph dwarf -F 99 -- ./app to sample, perf script to symbolize, perf report --stdio to rank symbols in text, and perf diff to compare two recordings. -F 99 samples ~99×/s at a rate that is not a multiple of 100, avoiding synchronization with periodic timer ticks that would bias the profile.
Question
Why is a CPU flamegraph misleading for an I/O-bound async service, and what do you use instead?
Click to reveal answer
Answer
It samples only running threads: time spent waiting on a database yields almost no samples, and frames read as poll state machines rather than logical await chains. For latency mysteries use wall-clock sampling or tracing (spans, tokio-console); for a pegged CPU, the flamegraph of the worker threads is exactly right.
Question
How do heap profilers attribute memory, and how do you tell a spike from a leak?
Click to reveal answer
Answer
Tools like heaptrack, dhat, or jemalloc_prof record each allocation with its backtrace, then rank call sites by bytes. Compare peak (t-gmax) with end-of-run: heavy at the peak but clean at the end is a transient spike; heavy at the end is a leak — followed by fixing the owner (unbounded cache, Rc cycle, dropped signal).
Revision Notes
Key Takeaways
- 1. Profile a release build with debug = 1: optimization reflects production, symbols make frames readable, and --call-graph dwarf (or forced frame pointers) is what lets the stack unwind
- 2. Width is the only data in a flamegraph — x-axis is arbitrary nesting order, and color is decoration unless the graph is differential
- 3. 99 Hz sampling is statistical: trust plateaus above a few percent, ignore slivers, and cross-check with perf report's ranked text view
- 4. A CPU flamegraph answers 'where is the time', not 'why is it slow' — waiting I/O and async poll chains need tracing or wall-clock sampling
- 5. Heap profilers attribute bytes to call sites; peak versus end-of-run separates allocation churn (temporary % high) from a real leak (heavy at the end)
Interview Tips
- • "A endpoint got 40% slower — what do you do?" — confirm with a benchmark (Criterion baseline), then flamegraph a release build with debug = 1, read the widest column, and verify with perf report; the sequence shows measurement before optimization
- • "How do you read a flamegraph?" — width = share of samples, y = stack depth, x is not time; distinguish self time (wide top) from cumulative (wide bottom) and say what you'd do with each
- • "Why is my profile full of `unknown` frames?" — missing symbols or unwind data: add debug = 1 and --call-graph dwarf / frame pointers; demangle with rustfilt
- • "CPU is idle but requests are slow — profile it?" — say a CPU sampler will show nothing; use tracing spans or wall-clock sampling, and keep the flamegraph for the pegged-CPU case
Cheat Sheet
Profiling with Flamegraphs and Perf
Prepare the binary:
[profile.release]
debug = 1 # symbols, still optimized
Record:
cargo install flamegraph
cargo flamegraph --release --bin server -- args
cargo flamegraph --release --bench parse
# underneath
cargo build --release
perf record --call-graph dwarf -F 99 -- ./target/release/server
perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg
Read:
| Signal | Meaning |
|---|---|
| Wide frame low in the tree | hot callee — that is where time goes |
| Wide frame at the top | self time in that function |
| Tall thin column | deep but cheap — ignore |
| x-axis position | nothing; it is nesting order, not time |
Cross-check / dig:
perf report --stdio --sort symbol # ranked text view
perf diff old.data new.data # change between recordings
RUST_BACKTRACE=1 perf report # demangle via rustfilt if needed
Heap: heaptrack ./app && heaptrack_print app.heaptrack.gz for top consumers and temporary-allocation %; in-process dhat::Profiler writes dhat.json (t-gmax vs end). Churn = big temporary % with clean end; leak = heavy at end.