← Back to roadmap
Phase 3Week 26~120 min read
0%·0/4

Profiling: perf, Flame Graphs & Counters

Stop guessing where the time goes.

Performance · Measure, Profile, Then Go Fast

Why this week

Every engineer has a story about the optimization that made no difference: a day spent hand-tuning a function that turned out to be 2% of the profile. The cure is boring and absolute — measure where the time goes before you change anything, and after you change it, measure that the time went away for the reason you think.

This week you learn the two instruments that make that possible on Linux. Sampling profiles (perf record plus flame graphs) tell you where the CPU spends its time. Hardware performance counters (perf stat) tell you why: is the core retiring instructions, waiting on memory, or throwing away work after mispredicted branches? Together they turn "the engine feels slow" into "38% of cycles are in Book::add, half of that is SipHash on the order-id map, and IPC there is 0.4 with 6 LLC misses per thousand instructions".

You will profile engine v2 under the open-loop load from week 25 — a profile of an idle engine, or of a microbenchmark, points you at the wrong code.

⚠️
Warning

Neither perf nor hardware counters are available in the Rust Playground (and many cloud VMs expose few or no PMU counters — perf stat prints <not supported>). The drills practise the reading: parsing a collapsed-stack profile and interpreting counter readouts. The capstone is where you run the real tools, on Linux on a machine you control.

Day 1 — How a sampling profiler works

Day 01

perf record

Interrupts, stacks and what you need in the binary

Docsby Brendan Gregg
Linux perf Examples

The practical reference. Read the sections on CPU statistics, sampling (perf record) and stack-walking problems. Keep the page open while you profile.

Bookby Brendan Gregg · ch. 6, 13
Systems Performance (2nd ed.)

Chapter 6 (CPUs) for the vocabulary — IPC, stall cycles, PMCs; chapter 13 is a whole chapter on perf. Read 13 selectively: perf stat, perf record, perf report, perf annotate.

Bookby Nicholas Nethercote et al.
The Rust Performance Book

The 'Profiling' and 'Build Configuration' chapters: which profilers work with Rust, debug info in release builds, frame pointers, and symbol demangling.

perf record programs a hardware counter (by default, cycles) to overflow every N events or at a target frequency. Each overflow raises an interrupt; the kernel records the instruction pointer and, with -g, a call stack. Run long enough and the samples are a statistical picture of where cycles go. Two consequences worth internalising:

  • It is statistical. 10,000 samples give you percentages to about ±1% for big frames; a function at 0.3% may be noise. Profile for tens of seconds, not two.
  • It only sees on-CPU time. A thread blocked in futex or recv is invisible to a cycles profile (Day 5 deals with that).

What the binary needs:

# Cargo.toml — profile what you ship, with enough metadata to read it
[profile.release]
debug = "line-tables-only"   # or true: file/line info and inlined-frame info
# Frame pointers make stack walking cheap and reliable
RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release

perf record -F 999 -g -- ./target/release/loadgen --rate 500000 --secs 30
perf report --no-children          # sort by self time
perf report                        # 'Children' column = inclusive time

Stack walking has three modes, each with a trade-off. Frame pointers (-g / --call-graph fp) are cheap but need every frame compiled with them — Rust omits them by default on x86-64 Linux, and a precompiled standard library may not have them, so stacks through std can be truncated. DWARF (--call-graph dwarf) copies a chunk of each thread's stack per sample (8 KiB by default) and unwinds offline — reliable, but big files and more overhead. LBR (--call-graph lbr) uses Intel's last-branch-record hardware — very cheap, but limited depth and Intel-only.

Why -F 999 and not 1000: an odd frequency avoids sampling in lockstep with something periodic (a 1 kHz timer tick, a 1 ms batch loop) and systematically over- or under-counting it.

Design note#profile-setup

Write down the exact commands you will use to profile engine v2 under the week 25 open-loop driver: build flags, perf record options, run length, and which CPU the profiled thread runs on. What would make the profile misleading?

0 chars

Day 2 — Reading flame graphs

Day 02

Width is time, height is depth, x is the alphabet

Drill 1: compute self and inclusive time from collapsed stacks

Docsby Brendan Gregg
Flame Graphs

The origin and the rules for reading them. Pay attention to: the x-axis is not time, colours are not meaningful by default, and the 'plateau' on top of a tower is where the CPU actually is.

Code
flamegraph-rs (cargo flamegraph)

A Cargo subcommand that runs perf (on Linux) and renders through inferno, the Rust port of Brendan Gregg's scripts. Convenient; but learn the manual pipeline too, because you will want to filter and diff the collapsed text.

The pipeline is three steps, and the middle one is the interesting one:

perf script > out.perf                                  # raw samples with stacks
inferno-collapse-perf out.perf > out.folded             # one line per unique stack
inferno-flamegraph out.folded > engine.svg              # render

The collapsed (folded) format is one line per unique stack with a sample count: main;engine::run;engine::Engine::submit;book::Book::add 60. Everything a flame graph shows can be computed from it:

  • A frame's width is its inclusive samples: every stack it appears in.
  • The top edge of a tower (frames with nothing above them) is self time: where the CPU was actually executing.
  • The x-axis is sorted alphabetically to merge identical frames — left-to-right order means nothing about time. (For time order, use a flame chart.)

The workflow: find the widest plateaus (big self time), then walk down to find the frame you own that is responsible. A wide core::hash::sip::Hasher::write is not a hashing problem to optimise — it is a hint that your Book::add hashes on the hot path at all.

Because the text format is so simple, grep and a few lines of code beat clicking around an SVG: filter to stacks containing Engine::submit, sum self time by frame, diff two profiles. That is the drill. One trap: Rust symbols contain spaces (<alloc::string::String as core::fmt::Write>::write_str), so the count is whatever follows the last space.

Drill

Find the hot frame in a collapsed stack

Target: 6/6 cases passNot yet run
use std::collections::HashMap;

/// One line of collapsed-stack output ("frame;frame;frame COUNT"), as
/// produced by stackcollapse-perf.pl or inferno-collapse-perf. Frames may
/// contain spaces (`<alloc::string::String as core::fmt::Write>::write_str`),
/// so the count is whatever follows the LAST space. Ignore surrounding
/// whitespace. Return None for blank or malformed lines.
pub fn parse_line(line: &str) -> Option<(Vec<&str>, u64)> {
    todo!()
}

/// Total samples across all valid lines (skip junk lines).
pub fn total_samples(collapsed: &str) -> u64 {
    todo!()
}

/// Self ("exclusive") samples: each line's count is credited to its leaf
/// frame only. Sort by count descending, then name ascending.
pub fn self_samples(collapsed: &str) -> Vec<(String, u64)> {
    todo!()
}

/// Inclusive samples: every frame on the stack gets the line's count — but
/// a frame appearing several times in one stack (recursion) counts once.
/// Same sort order as `self_samples`.
pub fn inclusive_samples(collapsed: &str) -> Vec<(String, u64)> {
    todo!()
}

/// The frame with the most self samples (ties: smallest name) and its share
/// of all samples in percent. None for an empty profile.
pub fn hottest(collapsed: &str) -> Option<(String, f64)> {
    todo!()
}
Rust
Recall#flame-width

In a CPU flame graph, what does a frame's width mean, and what does its horizontal position mean?

Recall#self-vs-inclusive

SipHash's write has the most self time, but book::Book::add has 41% inclusive. Which do you fix, and how?

Day 3 — Hardware counters and IPC

Day 03

perf stat

Drill 2: from counter readouts to a bottleneck

Paperby Ahmad Yasin (ISPASS 2014)
A Top-Down Method for Performance Analysis and Counters Architecture

The method behind Intel's top-down analysis: classify every pipeline slot as retiring, bad speculation, frontend bound or backend bound, then drill down. Read sections 1-3 for the model; the counter formulas are CPU-specific.

perf stat -e cycles,instructions,branches,branch-misses,cache-misses,LLC-load-misses \
    -- ./target/release/loadgen --rate 500000 --secs 30

The numbers that matter, and how to read them:

  • IPC (instructions per cycle). Modern big cores can retire four to six or more instructions per cycle at peak. A tight, cache-resident loop runs at 2–4. An order book chasing pointers through DRAM often runs at 0.3–0.8. Low IPC says "the core is waiting"; it does not yet say on what.
  • Branch-miss rate. A mispredict throws away the speculative work behind it — roughly 15–20 cycles on recent x86 cores. At 1% of branches it rarely matters; at 4%+ in a hot loop it can be a fifth of all cycles.
  • LLC misses and MPKI (misses per thousand instructions). Each last-level miss that goes to DRAM costs on the order of 80–100 ns — about 300 cycles at 3 GHz, more on big multi-socket servers. Five misses per thousand instructions is enough to make a workload memory-bound.

The crude cost model in the drill — misses × penalty ÷ cycles — is an upper bound, because an out-of-order core overlaps independent misses. That overlap is called memory-level parallelism (MLP): a pointer chase (each load's address depends on the previous load) has MLP ≈ 1; a scan over an array can keep ten misses in flight. Same miss count, very different cost — which is the whole argument for the dense, array-based book in week 28.

On recent Intel CPUs, perf stat --topdown (or the TopdownL1 metric group in newer perf) does this attribution properly, using counters designed for it; AMD's recent cores expose a similar pipeline breakdown. Use the real thing when you have it; use the arithmetic to sanity-check it.

Latency numbers · log scaleQuiz yourself →

Dot = typical modern x86 server; band = plausible range across hardware. Click a row for details.

Drill

Interpret IPC and miss rates

Target: 6/6 cases passNot yet run
/// A `perf stat` readout (cycles, instructions, branches, branch-misses,
/// LLC-load-misses).
#[derive(Clone, Copy, Debug)]
pub struct Counters {
    pub cycles: u64,
    pub instructions: u64,
    pub branches: u64,
    pub branch_misses: u64,
    pub llc_misses: u64,
}

/// What a stall costs on this machine, in core cycles, and how many LLC
/// misses the core overlaps on average (memory-level parallelism: ~1 for a
/// pointer chase, 5-10 for a streaming scan).
#[derive(Clone, Copy, Debug)]
pub struct CostModel {
    pub mispredict_penalty: f64,
    pub dram_latency_cycles: f64,
    pub mlp: f64,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bottleneck {
    Memory,
    BranchMispredict,
    Compute,
}

/// Instructions per cycle (0 when there are no cycles).
pub fn ipc(c: &Counters) -> f64 {
    todo!()
}

/// Branch misses as a percentage of branches (0 when there are no branches).
pub fn branch_miss_pct(c: &Counters) -> f64 {
    todo!()
}

/// LLC misses per thousand instructions (0 when there are no instructions).
pub fn llc_mpki(c: &Counters) -> f64 {
    todo!()
}

/// Fraction of cycles lost to mispredicts: misses * penalty / cycles,
/// clamped to [0, 1] (0 when there are no cycles).
pub fn branch_stall_frac(c: &Counters, m: &CostModel) -> f64 {
    todo!()
}

/// Fraction of cycles stalled on DRAM: llc_misses * dram_latency / mlp /
/// cycles, clamped to [0, 1] (0 when there are no cycles).
pub fn memory_stall_frac(c: &Counters, m: &CostModel) -> f64 {
    todo!()
}

/// Memory if memory_stall_frac >= 0.30 and it is at least the branch
/// fraction; otherwise BranchMispredict if branch_stall_frac >= 0.15;
/// otherwise Compute.
pub fn classify(c: &Counters, m: &CostModel) -> Bottleneck {
    todo!()
}
Rust
Recall#low-ipc-high-mpki

The engine's hot loop shows IPC 0.4 and 5 LLC misses per 1,000 instructions, with a 1% branch-miss rate. What is the likely bottleneck, and what kind of change fixes it?

Day 4 — How profiles lie

Day 04

Inlining, skid, symbols and the observer

Know the failure modes before you trust a profile

Inlining. In a release build most small functions are inlined, so their cost shows up under the caller. A profile that says Engine::submit is 60% self time may really be Level::push_back inlined into it. With debug info, perf report --inline (and perf annotate) can attribute inlined code; temporarily marking a suspect function #[inline(never)] also works — but remove it afterwards, because it changes what you are measuring.

Skid. The interrupt for a sample lands a few instructions after the one that caused the event, so perf annotate often blames the instruction after the expensive load. Precise sampling — Intel's PEBS via cycles:pp, AMD's IBS — reduces this. When annotating, look for the load just before the hot instruction.

Symbols. Rust symbols are mangled; perf demangles legacy-mangled names, and rustfilt handles the rest. A stripped binary profiles as raw addresses. Generics produce one symbol per instantiation — HashMap<OrderId, Loc>::insert and HashMap<u64, Account>::insert are different frames.

The environment. Profiling the kernel side needs a relaxed kernel.perf_event_paranoid or CAP_PERFMON. In VMs counters are often missing or virtualised. And perf itself costs something: at high frequency with DWARF stacks the overhead can be several percent and the profile can shift. Compare throughput with and without the profiler attached.

The workload. A microbenchmark that adds and cancels the same order a million times keeps everything in L1 and trains every branch. The same code under realistic open-loop flow from week 25 misses caches and mispredicts. Profile the system, not the microbenchmark.

Recall#skid

perf annotate shows 40% of samples on an add instruction right after a load from the order slab. What is probably going on?

Day 5 — Off-CPU time and the usual suspects

Day 05

Beyond the cycles profile

Blocking, scheduling, and a catalogue of engine hotspots

A busy-polling engine thread (week 30) spends all its time on-CPU, so a cycles profile describes it fully. The gateway and journal threads block: in epoll_wait, futex, fsync. Their latency problems are off-CPU — time spent waiting to be scheduled or for I/O — and are invisible to perf record -e cycles. Tools for that: perf sched (scheduler latency), perf trace (syscalls), and off-CPU flame graphs built from scheduler events (Brendan Gregg's site covers all three).

When a change claims a speedup, profile both versions and compare: differential flame graphs colour frames by how much they grew or shrank. And check the counters moved the way your theory says — if you "fixed cache misses", LLC-load-misses on that path should drop.

The hotspots you will most likely find in engine v2, and the usual fix:

  • malloc, free, RawVec::grow_one, realloc — an allocation per order or per event. Preallocate; slabs and pools (week 27).
  • core::hash::sip::Hasher — the default HashMap hasher (SipHash-1-3, DoS-resistant and comparatively slow) keyed by order id. A faster hasher for trusted keys, or a slab index instead of a hash lookup (week 27). The repo's books already use book::IdMap, a one-multiply hasher, so you'll only see this frame if your book uses the default HashMap.
  • BTreeMap search frames with low IPC — pointer-chasing price levels. A dense price ladder (week 28). In the repo this was the top hotspot, and this week's fix is a sorted Vec of prices per side (see the capstone).
  • core::fmt, format_inner, write_str — formatting or logging on the hot path. Binary events, formatting on another thread (weeks 19 and 38).
  • memcpy / memmove — large events copied by value, or VecDeque/Vec shifting elements. Smaller events; pass indices, not copies.
  • futex, syscall — locks or blocking queues between stages. Lock-free handoff (week 5) and busy polling (week 30).
Design note#hypotheses

Before you open the profile: write down the three hotspots you expect in engine v2 and the share of cycles you expect each to take. After profiling, compare. Which guess was most wrong, and what does that tell you about your mental model of the code?

0 chars

Day 6 — Fix, re-measure, write it up

Day 06

One change at a time

Hypothesis, change, profile, counters, latency

The discipline that makes optimisation work is the loop: hypothesis → one change → re-profile → counters → open-loop latency. Change one thing per iteration; if you change three and throughput goes up 20%, you have learned nothing about which one mattered, and one of them may have made the tail worse.

Keep an experiment log (it becomes week 40's report): commit, change, hypothesis, throughput, p99/p99.9 at a fixed offered rate, IPC, LLC MPKI, branch-miss rate, and a link to the flame graph.

Design note#experiment-log

Design the experiment-log template you will use for the rest of Phase 3. Which columns are mandatory for every entry, and what has to be fixed (machine, rate, build flags) for two entries to be comparable?

0 chars

Capstone

Profile engine v2 in crates/engine while the week 25 open-loop driver pushes realistic flow through it, find the two biggest hotspots, fix them, and prove both the speedup and its mechanism.

What to produce:

  • A profiling setup you can rerun: release build with line-table debug info and frame pointers, a fixed offered rate, the engine thread on a known core, perf record for at least 20–30 seconds after warmup. Commit the commands (a script or an xtask subcommand) so week 40 can rerun them.
  • Before/after artefacts: flame graphs (and the collapsed .folded files, which diff well) plus perf stat readings for each fix.
  • Two fixes, each with a one-paragraph explanation: what the profile showed, what you changed, which counter moved, and what happened to throughput and to p99.9 at the fixed rate.

What the repo checks. The w26_ tests (crates/engine/tests/w26_profiling.rs) don't care how you made the engine faster, only that it stayed correct and lost the costs a profile points at:

  • with a counting allocator, 200k steady-state commands of a mixed stream make zero allocations, and so do 200k commands that open and close price levels across ±400 ticks;
  • every event and the final state hash match a digest recorded before any fix, and a differential run against the naive matcher agrees;
  • a duplicate id is still rejected on every order path, after the static checks;
  • opening and closing a level at the touch costs about the same on a book 20,000 levels deep as on one 10 levels deep (within 10×);
  • a floor of 1M commands/s catches an accidental O(n) per command.

A BTreeMap price index fails both allocation tests, because the tree allocates and frees nodes as levels come and go. A Vec of levels sorted best-first fails the depth test, because opening a level at the touch shifts every level behind it.

What the reference found. Profiling the week 7 flow showed two hotspots. First, about 20% of engine time went to the B-tree search on every add away from the touch, and about 9% more to removing emptied levels. The fix makes each side of the week 6 Book a sorted Vec of prices with the best at the back: a level away from the touch is a binary search, and emptying the best level is a pop. Second, submit probed the order-id map twice: once for the duplicate check, and again inside add. That contains probe was about 10% of the profile. The fix is a fast path in Engine::submit. A GTC limit that doesn't cross can only fail the duplicate check, and add already makes that check in the probe that inserts the order. Neither fix changes a single event. Week 28's ladder removes the remaining binary search.

The benchmark. w26.engine_throughput is the closed-loop throughput of one Engine. A fresh engine replays the setup book (about 1,000 orders) and then 1M commands of the week 7 mixed flow back to back, and the median rate is reported. It isn't the open-loop run; that one is w25.engine_open_loop. The repo has no loadgen binary, so profile the bench binary itself. cargo bench -p engine --bench hft --no-run prints its path, and the binary takes a benchmark id as a filter, so perf record -g -- <path> w26.engine_throughput profiles exactly what the benchmark measures. The workspace's release profile, which bench inherits, already sets debug = "line-tables-only".

Pitfalls:

  • Optimising the wrong frame. Self time points at symptoms (hashing, memcpy, malloc); inclusive time and your own frames point at causes.
  • Benchmarking a different program. If the profiled run uses a different rate, build profile or book size than the benchmark, the fix may not transfer. Same binary, same flags, same load.
  • Throughput up, tail down. Batching and caching tricks can raise throughput while making p99.9 worse. Re-run the week 25 latency sweep, not just the throughput benchmark.
  • Leaving everything to weeks 27 and 28. Quick wins are fine (a hasher, a preallocated Vec, moving formatting off the hot path). But in the repo, weeks 27 and 28 add new book types beside the week 6 Book instead of rebuilding it, and Engine::new and Engine::with_config still build over Book. The w26 tests run on that engine, so Book itself has to stop allocating in steady state this week.

The throughput target is for the single-threaded engine on your machine; treat it as "the profile no longer has an obviously silly plateau". If you reach it with one fix, keep going until the top of the flame graph is your actual matching logic.

Capstone · in your exchange · crates/engine

Profile, fix two hotspots, prove it

Before/after flame graphs and perf stat readings for engine v2 under open-loop load; the top two hotspots fixed with the mechanism confirmed by counters.

cargo xtask test --week 26      # tests named w26_*
cargo xtask bench --week 26     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 26 tests pass w26_*
  • Engine throughput after profiling fixes w26.engine_throughputtarget ≥ 10.0M ops/s

Feeds into

  • Week 27 starts from this week's profile: whatever allocation frames you found are the hit list for the slab and pools.
  • Week 38 uses perf annotate and branch counters to split hot and cold paths, and the instruction-cache view of the same profiles.
  • Week 40 reruns this exact profiling setup on the final engine and puts before/after flame graphs in the performance report.