Find where CPU time actually goes by sampling stacks — and learn to read a flame graph.
🎯 Stop guessing what's slow — let the kernel show you the hot pathInstead of tracing every event, sample the running stack many times per second, aggregate identical stacks into counts, and draw the result as a flame graph — a picture that shows you exactly where the CPU is spending its time.
Everything so far in this course has been tracing: you attach a probe to a specific event (a syscall, a function entry, a kernel tracepoint) and your handler runs every single time that event fires. That's perfect for counting "how many open() calls happened" or "how long did each read take" — you want every occurrence.
But CPU usage is a different kind of question. When a program is burning a whole core and you ask "what is it actually doing right now?", there is no single event to attach to — the CPU is just executing code, millions of instructions a second. Tracing every function call to find the hot one would be absurdly expensive and would slow the very thing you're measuring.
The answer is sampling. Don't watch every event — instead, on a fixed timer, interrupt whatever is running, snapshot the call stack (the chain of functions currently executing), and move on. Do that ~100 times a second and the statistics take over: code that's on-CPU a lot gets sampled a lot; code that's rarely running barely shows up. You trade perfect precision for tiny, fixed overhead and a representative picture.
| Dimension | Tracing (event-driven) | Sampling (timer-driven) |
|---|---|---|
| Fires when… | a chosen event happens | a fixed timer ticks (e.g. 99×/sec) |
| Best question | "How many? How long? Which args?" | "Where is the CPU spending time?" |
| Overhead | scales with event rate (can be huge) | fixed & predictable (a few %) |
| Completeness | sees every event | statistical — rare code may be missed |
| bpftrace probe | kprobe, uprobe, tracepoint… | profile, interval |
profile probe — and why 99 Hzbpftrace exposes timed sampling through the profile probe. It fires on a timer, on every CPU, at a rate you choose. The unit after profile: sets how the rate is interpreted:
| Form | Meaning |
|---|---|
profile:hz:99 | 99 times per second, on each CPU (Hertz) |
profile:s:1 | once per second |
profile:ms:10 | every 10 milliseconds |
profile:us:100 | every 100 microseconds |
The canonical choice for CPU profiling is profile:hz:99 — but why the odd number 99 instead of a round 100?
99 Hz is also a deliberately cheap rate: frequent enough to catch both the big and small picture of where execution time goes, but not so frequent that the act of sampling perturbs the very performance you're trying to measure. That's the whole pitch of sampling — predictable, low overhead.
Source: bpftrace Reference Guide — profile probe, Brendan Gregg — a thorough introduction to bpftrace.
kstack and ustackA single sample is only useful if it records what code was running. That's the call stack: the chain of function calls that led to the currently-executing instruction. If main called handle_request which called parse_json, the stack at that instant is parse_json ← handle_request ← main.
bpftrace gives you two built-in variables that capture a stack on demand:
ustackUser-space stack — the call chain inside your application and its libraries (the functions you wrote, plus libc, your runtime, etc.). This is where app developers usually look first.
kstackKernel stack — the call chain inside the kernel (syscall handlers, filesystem, network, scheduler). Use this when the CPU is being burned in the kernel on your program's behalf.
ustack shows the first half; kstack shows the second. You can capture both in the same sample to see the whole story.
Each of these resolves the addresses to function names (using symbol tables) so you get readable frames like parse_json rather than raw hex addresses — assuming the symbols are present (more on that in §08).
@[ustack] = count()Here's the move that makes the whole thing work. You don't print every sample — that would be a firehose of thousands of stacks per second. Instead you use a bpftrace map keyed by the stack itself, and you count how many times each distinct stack appears.
# Profile USER stacks at 99 Hz; count each unique stack sudo bpftrace -e 'profile:hz:99 { @[ustack] = count(); }'
sudo).Read it left to right: 99 times a second, on every CPU, take the current user stack and use it as the key of map @; count() bumps that key's tally by one. Identical stacks land on the same key, so after 30 seconds the map holds "stack X was seen 4,210 times, stack Y was seen 12 times" — that is your profile, already summarized in the kernel before any data crosses to user space.
You can key by more than the stack. Add comm (the process/command name) to break the profile down per process, or capture both stacks at once:
# Per-process: which command, and its user stack sudo bpftrace -e 'profile:hz:99 { @[comm, ustack] = count(); }' # The full picture: kernel + user stack + process name sudo bpftrace -e 'profile:hz:99 { @[kstack, ustack, comm] = count(); }' # Profile just one PID, user stacks only, at 49 Hz sudo bpftrace -e 'profile:hz:49 /pid == 189/ { @[ustack] = count(); }'
sudo).The counted map is already the answer — but as text it's hard to read. Dozens of long stacks, each ending in a number, don't reveal the shape of where time goes. The breakthrough was to draw it. That's the flame graph, invented by Brendan Gregg to "visualize stack traces of profiled software so that the most frequent code-paths can be identified quickly and accurately."
Conceptually, picture every sampled stack standing upright as a tower of boxes (bottom frame = main, top = the function on-CPU). Now merge them: wherever many stacks share the same prefix — say thousands of them all start main → handle_request — those identical boxes roll up into one wider box. The more often a frame appeared across all samples, the wider its box becomes. Rare paths stay thin slivers.
@[stack] = count() map, turned sideways and drawn so that "counted a lot" becomes "drawn wide."
The classic pipeline turns bpftrace's text output into an interactive SVG with two small scripts from Gregg's FlameGraph toolkit:
# 1. collect a profile, save the raw stacks sudo bpftrace -e 'profile:hz:99 { @[kstack] = count(); }' > out.bpftrace # 2. "fold" each stack onto one line: frame;frame;frame count ./stackcollapse-bpftrace.pl out.bpftrace > out.folded # 3. render the folded stacks into an interactive flame graph ./flamegraph.pl out.folded > flame.svg
sudo). stackcollapse-bpftrace.pl and flamegraph.pl come from Brendan Gregg's FlameGraph repo.That middle "folded" format is the universal currency of flame graphs: one line per unique stack, frames separated by ;, followed by the sample count. Open flame.svg in a browser and you get hover-for-details, click-to-zoom, and search.
Source: Brendan Gregg — Flame Graphs.
This is the skill that pays off forever. A flame graph looks like a mountain range of stacked boxes — here's a realistic one. A web server's threads were sampled while serving traffic; the colors are just for contrast (warm = "hot/busy" CPU), they don't encode data.
@[ustack] = count(), drawn. Read top-down: the widest box on the top edge is your hottest code.Four rules and you can read any flame graph:
Width is the share of samples a frame appeared in — not the passage of time. Left-to-right has no chronological meaning; boxes are sorted alphabetically (which also maximizes merging).
Counting up from zero at the bottom. The base is the root caller (main); each row up is one call deeper. The top edge of each tower is the function that was actually on-CPU.
"The wider a frame is, the more often it was present in the stacks." A box's width includes all of its children, so a wide box means that whole subtree was busy.
Scan the top edge for the widest flat sections. That's where the CPU is genuinely spending cycles — your optimization target. In the diagram, utf8_decode under parse_json is the hot spot.
Source: Brendan Gregg — Flame Graphs (x-axis = stack profile population, sorted alphabetically, not time; y-axis = stack depth from zero at the bottom).
Everything above is on-CPU profiling: the profile probe fires on a CPU timer, so it can only sample a thread while that thread is actually running. That's exactly right for "my CPU is pegged — what's burning it?" But it has a blind spot.
Often a program is slow precisely because it isn't on-CPU — it's blocked: waiting on disk, a network reply, a lock, or a database. A timer-based profiler never samples a sleeping thread, so that waiting time is invisible in an on-CPU flame graph.
| On-CPU profiling | Off-CPU profiling | |
|---|---|---|
| Measures | time spent running on a CPU | time spent blocked / waiting |
| Answers | "what's burning CPU?" | "what's it waiting on?" |
| How captured | timer sampling (profile:hz:99) | tracing scheduler off-CPU events & the time asleep |
| Probe style | sampling | tracing (e.g. sched tracepoints) |
Source: Brendan Gregg — Flame Graphs (CPU vs Off-CPU Time variants).
Sampling and stack capture are powerful but not magic. Know the failure modes so a confusing flame graph doesn't mislead you.
Only if symbols are present. Stripped binaries, missing debug info, or JIT'd runtimes (JVM, V8, Python without a helper) leave frames as raw hex addresses or [unknown]. Install debug symbols / use a runtime-specific stack helper.
Inlined functions vanish — the compiler folded them into their caller, so they have no stack frame to sample. Time shows up attributed to the parent. Worth remembering before you conclude "function X is free."
Walking the stack needs frame pointers or DWARF/unwind info. Many builds compile with frame pointers omitted for speed, giving broken or truncated stacks. Rebuilding with -fno-omit-frame-pointer fixes it.
It's statistical. Very rare code may never be sampled, and tiny differences in width are noise. Sample long enough (tens of seconds) so the proportions stabilize.
Your go-to CPU-profiling repertoire. All Linux-only, all need sudo. Let one run for ~30 seconds, then Ctrl-C to print the aggregated map.
# Where is ALL CPU time going? Kernel stacks at 99 Hz sudo bpftrace -e 'profile:hz:99 { @[kstack] = count(); }' # User-space hot paths, per process name sudo bpftrace -e 'profile:hz:99 { @[comm, ustack] = count(); }' # One specific process (replace 189 with your PID), user stacks sudo bpftrace -e 'profile:hz:99 /pid == 189/ { @[ustack] = count(); }' # Full kernel + user + process, the raw material for a flame graph sudo bpftrace -e 'profile:hz:99 { @[kstack, ustack, comm] = count(); }'
sudo).And the three steps to turn any of those into an interactive SVG flame graph you can open in a browser:
sudo bpftrace -e 'profile:hz:99 { @[kstack] = count(); }' > out.bpftrace ./stackcollapse-bpftrace.pl out.bpftrace > out.folded ./flamegraph.pl out.folded > flame.svg # open flame.svg in a browser
sudo). Scripts from github.com/brendangregg/FlameGraph.profile:hz:99 conventionally used instead of a round profile:hz:100?ustack builtin capture in a sample?profile:hz:99 do, and why 99?kstack and ustack?kstack captures the kernel-space call stack (work the kernel is doing); ustack captures the user-space call stack (your app and its libraries). You can capture both in one sample to see the whole story.profile:hz:99) — "what's burning CPU?". Off-CPU = time spent blocked/waiting, which can't be sampled and is instead captured by tracing scheduler off-CPU events — "what's it stuck on?".This lesson rewards poking at it. Try asking me: "Walk me through reading a real flame graph step by step", or "Why exactly can't a timer-based profiler see blocked time?", or "My flame graph is full of [unknown] frames — what's wrong and how do I fix the symbols?" I can also generate a worked example from a folded-stacks file if you want to practice the read.