eBPF Course · Lesson 10

Capstone: Debugging "Why Is This Slow?" End-to-End

Put the whole course together — take a realistic slow-service mystery from symptom to root cause, reaching for the right eBPF tool at each narrowing step.

🎯 Mission: turn "it's slow and I don't know why" into a named root cause
The one idea

Real debugging is a drill-down: start broad — is the time spent on-CPU, on disk, or waiting? — then apply the right eBPF tool at each narrowing step until you can name the root cause.

01 The scenario: "the API is sometimes slow"

It's Tuesday. Your payments API is mostly fine — p50 latency is a healthy 40 ms — but support keeps forwarding the same complaint: "every so often a request just hangs for a couple of seconds." The dashboards are green. CPU looks normal. There's nothing useful in the logs. You can't reproduce it on your laptop.

This is the exact situation eBPF was made for. The service is already running in production, you can't add log lines and redeploy on a hunch, and the interesting behavior is intermittent. Instead of guessing, you're going to ask the running kernel precise questions until the mystery collapses into one sentence: "requests are slow because ______."

🎯 What this capstone doesEvery earlier lesson taught you one tool or idea in isolation. This lesson is the assembly: a single realistic investigation that uses them in sequence, the way you actually would on a real incident. Nothing new to memorize — just the workflow that ties it all together.

Throughout, every command is a Linux-only "try later" one-liner. You won't run anything here; you're learning to read the investigation and know which tool to reach for.

02 The method: don't poke randomly — drill down

The single biggest mistake under pressure is reaching for a favorite tool and hoping. The fix is a methodology — a fixed procedure that tells you what to look at first, so you stop guessing. The classic one is Brendan Gregg's USE method.

The USE method in one breath

For every resource (CPU, memory, disks, network interfaces), check three things:

U — Utilization

"The average time the resource was busy servicing work." A disk at 100% util is doing all it can.

S — Saturation

"The degree to which the resource has extra work it can't service, often queued." Saturation is where waiting (latency) is born.

E — Errors

"The count of error events." Cheap to check, so check them first — a retry storm or failing disk hides here.

Gregg's claim is that this simple loop "solves about 80% of server issues with 5% of the effort." It doesn't tell you the answer — it tells you which resource to interrogate next, so your eBPF tools point at the right place.

Source: Brendan Gregg — The USE Method · methodology & tooling expanded in BPF Performance Tools.

The drill-down decision tree

USE tells you to interrogate resources; the question that makes it concrete is "is my latency on-CPU or off-CPU?" That one split, then count-then-snoop, is the whole flowchart:

SYMPTOM "requests are slow" STEP 1 · Characterize Is the time spent ON-CPU, or WAITING (off-CPU)? ON-CPU · burning cycles CPU is high & busy → profile + flame graph (L7) OFF-CPU · waiting / blocked CPU idle but request stalls → latency histogram (L6) SUSPICIOUS VOLUME odd syscall / I/O activity → count by probe (L5) STEP 2 · Count, then snoop count events to find the hotspot — @[probe] = count() (L5) then snoop the offender — who & what (L3 / L4) STEP 3 · ROOT CAUSE NAMED "slow because of a missing-file retry storm + slow disk" Broad → narrow: never skip Step 1. Characterize first, then drill.
The drill-down: characterize (on-CPU vs off-CPU vs suspicious volume) → count to find the hotspot → snoop the offender → name the root cause. The lesson number on each branch is where you learned that tool.
💡 The mindset shiftYou're not "looking for the bug." You're narrowing the search space by half at every step — exactly like a binary search. Each eBPF tool answers one question that eliminates a whole category of cause.

03 Step 1 — Characterize: on-CPU or off-CPU?

Before any clever one-liner, answer the cheapest, most decisive question: when a request is slow, is the CPU busy doing work, or is it sitting idle while the request waits? This one split sends you down completely different paths.

 On-CPU (burning cycles)Off-CPU (waiting / blocked)
What's happeningThe thread is running, computing — a hot loop, JSON churn, a regex, crypto.The thread is parked by the scheduler, waiting on disk, a lock, the network, or a sleep.
CPU looks likeHigh & busyOften idle — the giveaway: slow but CPU isn't pegged.
Reach forSampling profiler → flame graph (Lesson 7)Latency histograms / off-CPU timing (Lesson 6)
It answers"Which function is eating the CPU?""What is it waiting on, and for how long?"

In our scenario the tell is loud: support says requests hang, and the CPU dashboards are green the whole time. Slow, but CPU is idle → this is off-CPU. Something is making the request wait. If instead the CPU had been pegged during slow requests, we'd grab a profiler and a flame graph (the on-CPU tool from Lesson 7) to see which function is hot. Here, we follow the off-CPU branch.

💡 Why this split is so powerfulA flame graph only shows you on-CPU time. If your problem is waiting on disk, the flame graph looks boring and idle — and you'd waste an hour staring at it. Characterizing first stops you from using the wrong lens.

On-CPU vs off-CPU framing per Brendan Gregg — Linux eBPF Tracing Tools and the USE method.

04 Step 2 — Follow the evidence: count, then snoop

We're on the off-CPU branch: requests wait. Now we narrow. The pattern is always the same two moves — count to find the hotspot, then snoop to see the detail. Three one-liners, each eliminating a guess.

① Count: what is the service spending syscalls on?

First, a wide-angle shot. Count every syscall the box makes, grouped by syscall name, so an abnormal one jumps out. @[probe] uses the firing probe's full name as the map key; count() tallies it (the map idea from Lesson 5).

# count every syscall entry, grouped by syscall name
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* {
                   @[probe] = count();
                 }'
Linux-only — copy and try later on a Linux box (needs sudo). Ctrl-C to stop; bpftrace prints the map on exit.

Expected output (sorted ascending, biggest last):

# ... long tail trimmed ...
@[tracepoint:syscalls:sys_enter_read]:    14233
@[tracepoint:syscalls:sys_enter_write]:   15901
@[tracepoint:syscalls:sys_enter_openat]: 288417   <-- whoa

What it tells you: openat — the syscall that opens files — is firing wildly more than anything else. A payments API should not be opening a quarter-million files. That's the thread to pull. (You could also key by @[comm] to see which process is responsible — the Lesson 3 builtins.)

② Snoop: which files, and is it retrying?

Counting found the suspicious syscall; now snoop it to see the actual filenames. This is the open-tracing one-liner you met early on (probes & arguments, Lesson 4): comm is the process name, args.filename is the path it's opening, and str() turns that kernel pointer into readable text.

# show every file open, system-wide: who opened what
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
                   printf("%-16s %s\n", comm, str(args.filename));
                 }'
Linux-only — copy and try later on a Linux box (needs sudo).

Expected output (streaming live):

payments-api     /etc/app/config.d/feature-flags.json
payments-api     /etc/app/config.d/feature-flags.json
payments-api     /etc/app/config.d/feature-flags.json
payments-api     /etc/app/config.d/feature-flags.json
# ... the same path, hundreds of times a second ...

What it tells you: the service is hammering one config file — feature-flags.json — over and over. A file it opens this often is almost certainly missing or unreadable, so a hot code path keeps retrying. That's a retry storm. (Confirm with the open-failure angle: trace tracepoint:syscalls:sys_exit_openat and check args.ret < 0 — a negative return is a failed open, e.g. -2 = ENOENT, "no such file.")

③ Measure: how slow is the disk underneath?

Why does a retry storm make requests hang rather than just spin CPU? Because each open touches the filesystem, and if the disk is slow, every retry blocks off-CPU. Confirm with a latency histogram (the distribution idea from Lesson 6). The idiom: stash a timestamp keyed by thread on the entry probe, then on the matching return probe record now − start into a hist().

# VFS read latency as a power-of-2 histogram (nanoseconds)
sudo bpftrace -e 'kprobe:vfs_read {
                   @start[tid] = nsecs;
                 }
                 kretprobe:vfs_read /has_key(@start, tid)/ {
                   @ns = hist(nsecs - @start[tid]);
                   delete(@start, tid);
                 }'
Linux-only — copy and try later on a Linux box (needs sudo). tid = thread id; nsecs = a nanosecond clock. For block-device latency specifically, the ready-made tool is biolatency.

Expected output (a bimodal histogram — the smoking gun):

@ns:
[1K, 2K)       48201 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2K, 4K)        9930 |@@@@@@@@@@                                      |
# ... fast page-cache reads above ...
[16M, 32M)      1422 |@                                               | <-- ~16-32 ms
[32M, 64M)       611 |                                                | <-- ~32-64 ms

What it tells you: most reads return in microseconds (served from cache), but a long tail takes tens of milliseconds — real disk hits. Stack a few dozen of those per slow request (the retry storm) and you get exactly the multi-second hang support reported. Two findings now interlock.

💡 Count-then-snoop, every timeCounting is cheap and wide — it finds the hotspot without drowning you in data. Snooping is detailed and narrow — once you know where, you look at exactly what. Never start by snooping everything; you'll get a firehose and no signal.

One-liner syntax verified against the bpftrace One-Liner Tutorial and bpftrace language reference (@[probe]=count(), str(args.filename), the @start[tid]=nsecs timing idiom). Recipes from BPF Performance Tools.

05 Step 3 — Confirm and name the root cause

The drill-down is done. Lay the evidence end to end and the one-sentence answer writes itself:

StepToolWhat it showed
Characterizeon/off-CPU splitSlow but CPU idle → off-CPU, it's waiting.
Count@[probe]=count()openat is firing absurdly often.
Snoopopenat tracerSame missing config file, retried in a hot loop.
Measureread-latency hist()Long tail of ~16–64 ms disk reads.
🎯 Root cause, named"Intermittent slow requests are caused by a missing feature-flags.json that a hot code path retries on every request; each retry is a filesystem open that occasionally hits the slow disk (16–64 ms), and a burst of retries stacks into a multi-second stall."

Fix hypothesis (a hypothesis — you confirm by deploying and re-measuring):

⚠ Correlation isn't proofTwo findings lining up is a strong hypothesis, not a verdict. The discipline is: change one thing, then re-measure with the very same one-liner. If the number doesn't move, you named the wrong cause — keep drilling.

06 Where each move came from (your course, assembled)

This whole investigation was just earlier lessons used in order. The capstone added no new tool — only the sequence:

Characterize the problem

On-CPU? → profile + flame graph (see Lesson 7). Off-CPU / waiting? → latency histograms (see Lesson 6).

Count to find the hotspot

Aggregating with maps — @[probe] = count() — is the core of Lesson 5. Cheap, wide, points at the offender.

Snoop the offender

Reading comm and arguments came from your first one-liner (Lesson 3) and choosing probes & reading their args (Lesson 4).

Measure the latency

The @start[tid]=nsecs → hist() timing idiom is timing & latency (Lesson 6); BCC tools like biolatency live in Lesson 8.

Do it on prod, safely

Why you can run all of this live without fear — overhead, the verifier, blast radius — is Lesson 9.

Why it's even possible

The whole "ask the running kernel a question" model started in Lesson 1 — events, the verifier, maps. Everything since is detail on that.

💡 The transferable skillThe exact bug here (a retry storm on a missing file) doesn't matter. The shape does: characterize → count → snoop → measure → name → verify. Memorize the shape and you can debug a mystery you've never seen.

07 Your turn — reach for the right tool

A fresh mystery lands on you. Walk the drill-down in your head before peeking:

⚠ New symptomA batch job that used to finish in 5 minutes now takes 25. While it runs, CPU is pegged at 100% the entire time — no idle, no obvious waiting. Nothing changed in the input size.

Work it through

  1. Characterize: CPU is pegged → this is on-CPU, not waiting. (Off-CPU latency histograms would look boring here.)
  2. Which tool? The on-CPU branch → a sampling profiler + flame graph (Lesson 7) to see which function is eating the cycles.
  3. Then what? If the flame graph fingers a suspicious function, count how often it's called and snoop its arguments (L5 then L3/L4) to find why it got hot — maybe an accidental O(n²) over a grown dataset.

Notice: the procedure is identical to the capstone — only the first branch flipped from off-CPU to on-CPU. That's the whole game.

08 Where to go next — make it real

You've finished the conceptual arc. Two ways to turn it into a durable skill:

🧑‍🏫 Bring me a real problem

The fastest way to cement this: take an actual slow/flaky/mysterious thing you've hit — at work or in a side project — and we'll walk the drill-down together. Which branch? Which one-liner? I'll help you read the output and name the cause.

🐧 Get a Linux box

Everything here is run-it-later until you have Linux. A cloud VM or a local Linux VM unlocks running these one-liners for real. Ask me for a vetted "Linux on a Mac" setup when you're ready (it's a noted gap in RESOURCES.md).

💬 Test it in the community

When you want to pressure-test your understanding against real practitioners: the eBPF community Slack (newcomer + bpftrace channels) and r/eBPF. See RESOURCES.md for all of it.

📚 Go deeper on recipes

For a subsystem-by-subsystem cookbook of debugging recipes, BPF Performance Tools is the definitive next step — 150+ tools organized exactly like this drill-down.

Communities & reading per RESOURCES.md and ebpf.io — Get Started.

09 Check yourself

Scenario questions — pick the right tool for each. Instant feedback, no grading.

1. A request is slow and CPU is pegged at 100% the whole time. What's your move?
2. A request is slow but CPU is idle — it seems to be waiting on disk. Which tool fits?
3. Your @[probe] = count() shows openat firing 200× more than any other syscall. What does that spike most suggest?
4. A mystery service is "just slow." What's the correct first step in the drill-down?

10 Flashcards

Click to reveal. Quick review of the capstone's load-bearing ideas.

Q: What is the USE method, in one line?
A: For every resource (CPU, memory, disks, network), check Utilization (how busy), Saturation (queued, unservable work — where latency lives), and Errors (count of error events). It points you at the bottlenecked resource fast.
Q: What's the very first split when something is "slow"?
A: On-CPU vs off-CPU. On-CPU = the thread is busy computing → use a profiler + flame graph (L7). Off-CPU = it's parked/waiting (disk, lock, network) → use latency histograms / off-CPU timing (L6). The tell: slow but CPU idle means off-CPU.
Q: What does "count, then snoop" mean and why that order?
A: First count events with a map (@[probe] = count()) — cheap and wide, it finds the hotspot. Then snoop just that offender to see the detail (filenames, args). Snooping first would be a firehose with no signal.
Q: When do you reach for a latency histogram, and what does it show?
A: When the problem is off-CPU/waiting and you want the distribution of how long an operation takes. A hist() reveals the shape — e.g. a bimodal split where most reads are fast (cache) but a long tail hits slow disk (tens of ms). Averages hide that tail; histograms expose it.
Q: Describe the drill-down mindset in one sentence.
A: Don't hunt for the bug — narrow the search space by half at each step (characterize → count → snoop → measure → name → verify), reaching for the one eBPF tool that answers each question, until the root cause is a single sentence.
Q: When should you escalate to a community instead of grinding solo?
A: When you've done the drill-down but are stuck reading output, picking a probe, or interpreting a result — that's the moment to bring it to the eBPF Slack, r/eBPF, or your teacher. Communities are for testing understanding against real practitioners, not a first resort.
👩‍🏫 I'm your teacher — ask me anything

This is the capstone, so let's make it stick: bring me a real "why is this slow?" you've actually hit and we'll walk the drill-down together. Or try: "Re-run this investigation but for a memory leak — which branch and which tools?" or "What would the flame-graph version of Step 1 have looked like?"