eBPF Course · Lesson 7

CPU Profiling & Flame Graphs

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 path
The one idea

Instead 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.

01 Tracing vs sampling — when each wins

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.

DimensionTracing (event-driven)Sampling (timer-driven)
Fires when…a chosen event happensa fixed timer ticks (e.g. 99×/sec)
Best question"How many? How long? Which args?""Where is the CPU spending time?"
Overheadscales with event rate (can be huge)fixed & predictable (a few %)
Completenesssees every eventstatistical — rare code may be missed
bpftrace probekprobe, uprobe, tracepoint…profile, interval
💡 The mental flip For "what's slow / what's hot on the CPU", reach for sampling, not tracing. A sample is a cheap photo of the stack; a profile is thousands of those photos summed up.

02 The profile probe — and why 99 Hz

bpftrace 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:

FormMeaning
profile:hz:9999 times per second, on each CPU (Hertz)
profile:s:1once per second
profile:ms:10every 10 milliseconds
profile:us:100every 100 microseconds

The canonical choice for CPU profiling is profile:hz:99 — but why the odd number 99 instead of a round 100?

⚠️ Why 99, not 100 Many other timed activities in a system fire at round rates — multiples of 100 (timers, schedulers, periodic jobs). If you also sampled at exactly 100 Hz you risk falling into lockstep: your sample lands at the same phase of that periodic activity every time, so you'd always catch it right before or right after — a biased, distorted picture. Picking 99 Hz keeps your sampling out of step with those round-number cycles, so you see a fair cross-section.

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.

03 Capturing the stack — kstack and ustack

A 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:

ustack

User-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.

kstack

Kernel 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.

💡 Two halves of one picture A thread spends time in user space (your code) and in kernel space (work done for it). 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).

Source: bpftrace One-Liner Tutorial — kstack / ustack.

04 Aggregating stacks — @[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(); }'
Linux-only — copy and try later on a Linux box (needs 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(); }'
Linux-only — copy and try later on a Linux box (needs sudo).
🎯 Why this is the mission in miniature No code change, no restart, no log lines. You attach to a running process, and in seconds the kernel hands back a frequency-counted map of exactly where its CPU time went — aggregated in-kernel for almost no overhead.

Source: bpftrace One-Liner Tutorial — profiling lesson.

05 From aggregated stacks to a flame graph

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.

💡 The roll-up, in one line Identical stacks merge; width = how many samples shared that frame. A flame graph is just your @[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
Linux-only — copy and try later on a Linux box (needs 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.

06 Reading a flame graph

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.

y = stack depth (caller → callee) main (100% of samples) handle_request parse_json (50%) db_query (33%) render utf8_decode ← THE HOT SPOT net_recv ▲ wide plateau at the top = where the CPU actually is x = proportion of samples (NOT time) · sorted alphabetically, not chronologically a frame's WIDTH = how often it was on-CPU
The same data as @[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:

x-axis = proportion of samples

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).

y-axis = stack depth

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.

width = how often it was 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.

look for wide top plateaus

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.

⚠️ The single most common misread Beginners read a flame graph left-to-right as a timeline. It isn't one. There is no time on the x-axis — only "how much of the profile this frame accounts for." A box on the far left is not "earlier" than one on the right.

Source: Brendan Gregg — Flame Graphs (x-axis = stack profile population, sorted alphabetically, not time; y-axis = stack depth from zero at the bottom).

07 On-CPU vs off-CPU profiling

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 profilingOff-CPU profiling
Measurestime spent running on a CPUtime spent blocked / waiting
Answers"what's burning CPU?""what's it waiting on?"
How capturedtimer sampling (profile:hz:99)tracing scheduler off-CPU events & the time asleep
Probe stylesamplingtracing (e.g. sched tracepoints)
💡 Forward reference Off-CPU time can't be sampled (there's nothing running to sample) — it's measured by tracing when a thread goes off and comes back on the CPU, then attributing the asleep time to the stack that blocked. You can render that as an off-CPU flame graph too. We'll fold both styles into your toolkit in the next lessons. For now: on-CPU = where it's busy, off-CPU = where it's stuck.

Source: Brendan Gregg — Flame Graphs (CPU vs Off-CPU Time variants).

08 Limitations & honest caveats

Sampling and stack capture are powerful but not magic. Know the failure modes so a confusing flame graph doesn't mislead you.

"Every box will have a clean function name."

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.

"I'll see every function I wrote."

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."

"Stack unwinding always works."

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.

"Sampling sees everything."

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.

⚠️ Read the shape, not single pixels A flame graph is a statistical estimate. Trust the wide plateaus; treat one-sample-wide slivers and hairline width differences as noise, not findings.

09 Copy-later one-liners

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(); }'
Linux-only — copy and try later on a Linux box (needs 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
Linux-only — copy and try later on a Linux box (needs sudo). Scripts from github.com/brendangregg/FlameGraph.
🎯 The payoff One command, thirty seconds, zero code changes — and you walk away with a picture that points straight at the hottest function in a live system. That's the entire promise of eBPF observability, made visual.

10 Check yourself

You want to find which function is burning the most CPU in a busy live service. Which approach fits?
In a flame graph, what does the width of a frame represent?
Why is profile:hz:99 conventionally used instead of a round profile:hz:100?
What does the ustack builtin capture in a sample?

11 Flashcards

Q: What does profile:hz:99 do, and why 99?
A: It fires a sampling probe on every CPU 99 times per second. 99 (not 100) avoids falling into lockstep with periodic activity that runs at round multiples of 100 — which would bias the profile. It's also a deliberately cheap, low-overhead rate.
Q: Difference between kstack and ustack?
A: 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.
Q: How is sampling different from tracing?
A: Tracing fires a handler on every occurrence of a chosen event (overhead scales with event rate). Sampling fires on a fixed timer and snapshots whatever is running, giving cheap, predictable overhead and a statistical picture of where the CPU spends time.
Q: In a flame graph, what does a frame's width mean?
A: The proportion of samples that frame appeared in — how often it was on-CPU. It is not time, and the x-axis is sorted alphabetically, not chronologically. Wider = more of the profile.
Q: How do you spot the hot spot in a flame graph?
A: Scan the top edge (the on-CPU functions) for the widest flat plateaus. A wide top plateau is where the CPU is actually spending its cycles — your optimization target.
Q: On-CPU vs off-CPU profiling?
A: On-CPU = time spent running on a CPU, captured by timer sampling (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?".
👩‍🏫 I'm your teacher — ask me anything

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.