Module 14 — Performance Methodology & Tracing

Updated 22 August 2026

Module 14 · Performance methodology and tracing

Modules 07 to 13 each handed you a set of numbers: run queues, page faults, iowait, cgroup pressure, capability masks. This module is about the part nobody teaches — which number to look at first, and how to get from "the service is slow" to a named cause without guessing.

🧠 concept → 🧩 real-world analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start, you should already know:

From Module 07 — run queue, load average including D-state, and that iowait is a subset of idle.

From Module 04 — signal masks in /proc/<pid>/status.

From Module 08 — minor and major page faults.

From Module 09 — page cache, MemAvailable, and PSI.

From Module 10iostat -x, and that %util is meaningless on a parallel device.

From Module 12 — cgroups, and cpu.stat's nr_throttled.

From Module 13 — capabilities, seccomp, and that --privileged turns the syscall filter off.

Tools used here: sysstat (pidstat, mpstat, iostat, sar), procps (vmstat, free, uptime), strace, and perf. On Debian/Ubuntu: sudo apt-get install -y sysstat strace linux-tools-common linux-tools-generic. On RHEL-family: sudo dnf install -y sysstat strace perf.


🧭 Part A · Method before tools

A1 · Why "check top first" is the wrong instinct

Everyone has a first command. top, or htop, or whatever their last team used. It is not a bad command — but starting there means you are not investigating, you are checking your favourite place, and the outcome depends on whether the problem happens to live there.

Brendan Gregg gave these habits names, and recognising your own in the list is the first useful step:

Anti-methodWhat it looks likeWhy it fails
StreetlightYou run the tools you know, because you know themThe drunk looking for keys under the streetlight, because the light is better there. It finds problems only where your tools already point
Random changeChange a setting, redeploy, see if it helpedYou cannot tell a fix from a coincidence, and every change is now permanent because nobody dares revert it
Blame someone else"It's the network." "It's the database."Costs another team a day, and produces no evidence either way
Tool-first"Let's get a flame graph."Sometimes right by luck. A flame graph of a process that is blocked on disk shows you nothing at all

A method replaces all of them with the same idea: enumerate everything that could be the answer, then eliminate. You start from a list, not from a tool, and the list is the thing worth memorising.

The counter-intuitive part. Performance work feels like it should reward knowing the most tools. It does not. It rewards having a complete list of suspects, because the failure mode that costs you the afternoon is never "I did not know that command" — it is "I never thought to look there." Two engineers with the same tools and different methods produce wildly different results, and the one with the method is usually the slower typist.
Real-world analogy — the car that will not start

Two mechanics. The first opens the bonnet and checks the spark plugs, because the last three cars that would not start had bad plugs. If it is the plugs, they are a hero in four minutes. If it is not, they are now looking at the alternator, then the starter motor, in whatever order occurs to them, and at some point they will start replacing parts to see what happens.

The second reaches for a checklist that came with the car: does it crank? Is there fuel pressure? Is there spark? Is there compression? Four questions, and every possible cause of "will not start" is inside one of them. They may not be faster on the plug-fouling case. They are dramatically faster on everything else, and — the part that matters at three in the morning — they can tell you when they are finished, because the list is finite.

The first mechanic's real problem is not ignorance. They may know more about engines than the second. Their problem is that they have no way to know what they have not checked.

Where the analogy stops working. A car has one fault at a time and a fixed parts list. A distributed system routinely has two interacting causes, and the list of resources grows every time someone adds a service — which is why the methods below are framed around categories of resource rather than specific components.

🧪 Exercise A1.1 — Ask the machine three questions in ten seconds

Before reading Section A2, run this on a machine doing something. Time yourself.

bash
# Put two CPU burners on the box so there is something to see
for i in 1 2; do ( timeout 15 bash -c 'x=0; while :; do x=$((x+1)); done' & ); done
sleep 3

# Question 1: is this getting worse, or has it always been like this?
uptime

# Question 2: where is the time going, machine-wide?
vmstat 1 3

# Question 3: is it every CPU, or one hot CPU?
mpstat -P ALL 1 2 | tail -6
Expected result — click to reveal
plain text
 11:57:45 up  4:49,  0 user,  load average: 0.21, 0.17, 0.18

procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st gu
 2  0      0 5796084  20376 1754580    0    0   169   100  992    7  2  3 96  0  0  0
 2  0      0 5796780  20376 1754600    0    0     0     0  922  955 97  3  0  0  0  0
 2  0      0 5796840  20376 1754600    0    0     0     0  656  755 97  3  0  0  0  0

Average:     CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
Average:     all   96.48    0.00    3.02    0.00    0.00    0.00    0.00    0.00    0.00    0.50
Average:       0   98.99    0.00    1.01    0.00    0.00    0.00    0.00    0.00    0.00    0.00
Average:       1   94.00    0.00    5.00    0.00    0.00    0.00    0.00    0.00    0.00    1.00

What to read out of this — and notice how much three commands settled.

uptime answers "is this new?" Three seconds after starting two CPU burners it shows nothing: 0.21 0.17 0.18, essentially flat. That is the first lesson, and it is better than the one you were expecting. The load average is a 1-, 5- and 15-minute exponentially weighted moving average, sampled every five seconds — three seconds of two runnable tasks can add at most about 0.1 to the first figure. Use uptime to answer "has this been going on a while?", and never "did something just start?"vmstat's r column answers that instantly. (Leave the burners running for 45 seconds and re-run it, and you will watch the first number climb away from the other two.)

vmstat answers "which resource?" Ignore the first line's rate columns — bi, bo, in, cs, us, sy, id, wa are averages since boot, not a sample, and misreading them is the most common vmstat mistake. (r, b and the memory columns are current, which is why r already reads 2 on that first line.) On the real samples: r is 2 with 2 CPUs, so the run queue is full but not backed up. us is 97 and sy is 3, so this is application work, not kernel work. wa is 0, so nothing is waiting on disk. si/so are 0, so there is no swapping. b is 0, so nothing is blocked. Four whole categories of cause eliminated in three lines.

mpstat -P ALL answers "everywhere, or one place?" Both CPUs are near 100%, so this is genuinely CPU-bound work spread across the machine. Had one CPU been at 100% and the rest idle, the story would be a single-threaded bottleneck, and the fix would be completely different.

Check %steal every time you look at this on a VM. It is 0 here. Anything sustained above a few percent means the hypervisor took your CPU away, and no amount of optimising your code will help.

If your vmstat has no gu column, you are on an older procps. It is guest time, and on most hosts it is zero.

Now imagine this at 500 hosts. The reason to learn a fixed checklist is that it is the only thing that survives being handed to someone else. A senior engineer's intuition does not fit in a runbook and does not work at 3 a.m. on the tenth incident of the week. Ten commands with a written interpretation of each do. Put the checklist in the runbook, and put its output in the incident template so the first responder collects it before they start theorising.

A2 · The USE method — one question per resource

For every resource, ask three questions. Gregg's definitions, in his words:

Utilization"the average time that the resource was busy servicing work"
Saturation"the degree to which the resource has extra work which it can't service, often queued"
Errors"the count of error events"

Gregg's own resource list is longer than most people quote: it includes controllers and interconnects, storage capacity as distinct from storage I/O, and software resources such as mutexes and thread pools. The four that carry most of the weight on a Linux box are CPU, memory, storage I/O and network — with the same three questions each, that is twelve checks, short enough to hold in your head:

ResourceUtilizationSaturationErrors
CPUmpstat -P ALL 1, vmstat 1 (us+sy+st)vmstat 1r greater than nproc; /proc/pressure/cpu; cpu.stat nr_throttledRare. perf processor error events, where a PMU exists
Memoryfree -m — read available, not freevmstat 1si/so; /proc/pressure/memory; pgsteal_directdmesg | grep -i 'killed process'; memory.events oom_kill
Storage I/Oiostat -xz 1r/s, w/s, throughputaqu-sz, r_await/w_await; /proc/pressure/iosmartctl -a; dmesg for I/O errors
Networksar -n DEV 1; ip -s linkss -s; nstat for retransmits and listen overflowsip -s linkerrors, dropped; sar -n EDEV 1
The counter-intuitive part, and the reason the method has three questions instead of one: utilization is a lagging indicator, saturation is the leading one.

A resource at 100% utilization with no queue is perfectly healthy — it is simply fully used, and every request is still served immediately. A resource at 70% utilization with a growing queue is already hurting, and will get dramatically worse with a small increase in load.

This is why dashboards built entirely on utilization gauges mislead. "We're at 65% CPU, we have plenty of headroom" is a statement about the wrong number. The number that predicts the outage is the run queue, or PSI, or the connection backlog — all of which are saturation. Section A4 shows why the relationship between the two is not a straight line.

What USE is not for. Gregg is explicit: it "solves about 80% of server issues with 5% of the effort", and there are "many problem types it doesn't solve". It is a resource checklist. It will not find a bad database query plan, a lock convoy in your application, a slow downstream dependency, or a retry storm — none of those saturate a machine resource. When USE comes back clean and the service is still slow, that is a result, not a failure: it tells you to stop looking at the host and start looking at the request path, which is Section A3.
Real-world analogy — the supermarket checkout

Stand at the front of a supermarket and you can describe every till with three numbers.

Utilization is what fraction of the time the till is actually scanning something. Saturation is how many people are standing in the queue. Errors are the items that will not scan and the card machine declining.

Now the observation that makes the method click. A till that is scanning 100% of the time with nobody waiting is the best-run till in the shop — fully used, and every customer served the moment they arrive. A till that is scanning 70% of the time with nine people queueing is a disaster, and the manager staring at a utilization dashboard would see the first as the problem.

The queue is what customers actually feel, and it is also what tells you the future: a queue of nine at ten past ten is a queue of twenty by half past. Utilization tells you what happened. The queue tells you what is about to happen.

And the errors column is the one people forget entirely. A till at 40% utilization with no queue, declining one card in five, is failing badly — and neither of the other two numbers shows it.

Where the analogy stops working. A supermarket queue is visible from the door. Most Linux saturation is not: the run queue, the disk queue depth and the socket accept backlog are all invisible unless you go and read them, which is exactly why the checklist exists.

🧪 Exercise A2.1 — Walk the whole checklist once, on an idle machine

Do this while nothing is wrong. Knowing what "healthy" looks like on your machine is what makes the same commands useful when something is.

bash
echo "===== CPU ====="
echo "-- utilization"; mpstat 1 1 | tail -2
echo "-- saturation: r should be <= $(nproc)"; vmstat 1 2 | tail -1
echo "-- saturation: PSI"; cat /proc/pressure/cpu 2>/dev/null || echo "  no /proc/pressure - PSI not enabled on this kernel"

echo "===== MEMORY ====="
echo "-- utilization"; free -m
echo "-- saturation: si/so are columns 7 and 8 above; also:"; cat /proc/pressure/memory 2>/dev/null || echo "  (no PSI)"
echo "-- errors"
if dmesg >/dev/null 2>&1; then dmesg | grep -ci 'killed process'
else echo "  dmesg not readable as this user (kernel.dmesg_restrict=1)"; fi

echo "===== STORAGE ====="
iostat -xz 1 2 | tail -6

echo "===== NETWORK ====="
echo "-- utilization"; sar -n DEV 1 1 2>/dev/null | tail -4
echo "-- errors"; ip -s link | head -8
Expected result — click to reveal (abridged; yours will differ)
plain text
===== CPU =====
-- utilization
11:59:21     all    1.02    0.00    4.57    0.00    0.00    0.00    0.00    0.00    0.00   94.42
-- saturation: r should be <= 2
 0  0      0 5796840  20376 1754600    0    0     0     0  656  755  1  4 95  0  0  0
-- saturation: PSI
  no /proc/pressure - PSI not enabled on this kernel
===== MEMORY =====
-- utilization
               total        used        free      shared  buff/cache   available
Mem:            8023         852        5686           4        1733        7170
Swap:              0           0           0
-- errors
0
===== STORAGE =====
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           0.51    0.00    4.55    0.00    0.00   94.95

Device            r/s     rkB/s   rrqm/s  %rrqm r_await rareq-sz     w/s     wkB/s ...

===== NETWORK =====
Average:         eth0      8.00      8.00      1.20      2.03      0.00      0.00      0.00      0.00
Average:      docker0      0.00      0.00      0.00      0.00      0.00      0.00      0.00      0.00
-- errors
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    RX:  bytes packets errors dropped  missed   mcast
     703885134  102123      0       0       0       0

What to read out of this.

Memory is the line most people misread. free is 5686 MB and available is 7170 MBavailable is larger, because most of buff/cache is reclaimable page cache the kernel will hand back on demand (Module 09). Always read available. A monitoring alert on free will page you about a perfectly healthy machine every single time.

The storage section shows an empty device list, and that is -z doing its job: it omits devices with no activity in the interval. An empty table means an idle disk, not a broken command. It is also worth knowing that in a container, or on overlay and network storage, the device you think you are using may not appear as a block device at all — which is itself a finding.

/proc/pressure is missing here. PSI needs CONFIG_PSI=y, and on kernels built with CONFIG_PSI_DEFAULT_DISABLED=y it additionally needs psi=1 on the kernel command line. When it is present it is the best saturation signal on the machine, because it measures the thing USE calls saturation directly rather than by proxy. Check for it once per platform, and if it is missing, say so in the runbook rather than letting people wonder.

The network errors block is the one nobody checks. errors and dropped at zero is the answer you want. Non-zero and growing is a real finding, and it is invisible in every CPU and memory dashboard ever built.

Now imagine this at 500 hosts. Build the dashboard around the saturation column, not the utilization column. Run queue depth, PSI some/full, disk aqu-sz and TCP listen overflows are the four that predict incidents; CPU and memory utilization gauges mostly tell you what already happened. And put the errors column somewhere visible — interface drops and OOM kills are cheap to collect, unambiguous when non-zero, and almost never on anyone's dashboard.

A3 · RED and the Four Golden Signals — the view from the request

USE looks up from the machine. The complementary method looks down from the request, and it is the one that matches what a user actually experiences.

The Four Golden Signals come from chapter 6 of Google's SRE book, written by Rob Ewaschuk. In its words:

Latency"The time it takes to service a request"
Traffic"A measure of how much demand is being placed on your system, measured in a high-level system-specific metric"
Errors"The rate of requests that fail, either explicitly (e.g., HTTP 500s), implicitly (for example, an HTTP 200 success response, but coupled with the wrong content), or by policy"
Saturation"How 'full' your service is… emphasizing the resources that are most constrained"

RED — Rate, Errors, Duration — is the same idea trimmed to three, named and popularised by Tom Wilkie, during his time at Weaveworks — see The RED Method. The relationship is worth being able to state in one line: RED is the Golden Signals minus Saturation, and the missing one is the reason you still need USE.

MethodPoint of viewAnswersBlind to
USEResources, bottom-upWhich resource is the bottleneck?Bad queries, lock contention, slow dependencies, retry storms
RED / Golden SignalsRequests, top-downAre users suffering, and where?Which resource to fix. It tells you the checkout is slow, not which till
The single biggest mistake in this whole module: reporting the mean latency.

The mean is not a percentile and it is not a typical experience. A handful of very slow requests drag it upwards until it describes nobody — you will see this in the exercise below, where the mean is higher than the p99. Report p50, p99 and p99.9, and always alongside the request count, because a p99 over eleven requests is noise.

Two more rules that separate good latency reporting from decorative latency reporting. Never average percentiles — the mean of each server's p99 is not the fleet's p99, and there is no arithmetic that recovers it from the summaries. And separate successful from failed requests: a fast-failing dependency makes your latency graph look better while the service is broken.

Real-world analogy — the kitchen and the dining room

USE is standing in the kitchen. Are all four ovens in use? How many tickets are pinned up waiting? How many dishes came back? It is precise, it is measurable, and it tells you exactly which piece of equipment is the constraint.

RED is standing in the dining room. How many orders came in this hour? How many diners sent food back? How long from ordering to eating? None of that names a piece of equipment, and all of it is what the diners will tell their friends about.

You need both, because each is blind in a way the other is not. A kitchen where every oven is idle and every ticket is served in four minutes looks perfect — while the dining room waits forty minutes, because there is one waiter and the orders never reach the kitchen. The bottleneck is not always inside the thing you instrumented.

And the averaging trap has a dining-room version. "Average wait: twenty minutes" describes nobody if ninety-nine tables waited five minutes and one table waited four hours. The table that waited four hours is the one writing the review.

Where the analogy stops working. A restaurant serves one kind of customer. A service usually has several request types with wildly different natural durations, and mixing a health check into the same latency histogram as a report query will hide both.

🧪 Exercise A3.1 — Watch the mean lie to you

A thousand requests: nine hundred and ninety fast ones, ten slow ones. No servers required — this is arithmetic.

bash
# 990 requests at 5-15 ms, 10 requests at 800-1200 ms
awk 'BEGIN{srand(42);
  for(i=1;i<=990;i++) printf "%.1f\n", 5+rand()*10;
  for(i=1;i<=10;i++)  printf "%.1f\n", 800+rand()*400;
}' > /tmp/lat.txt
sort -n /tmp/lat.txt > /tmp/lat.sorted

awk '{s+=$1} END{printf "mean  %8.1f ms\n", s/NR}' /tmp/lat.sorted
for p in 50 90 95 99; do
  awk -v p=$p 'NR==int(1000*p/100){printf "p%-4s %8.1f ms\n", p, $1}' /tmp/lat.sorted
done
awk 'NR==999{printf "p99.9 %8.1f ms\n", $1}' /tmp/lat.sorted   # rank 999, not 1000

awk -v m="$(awk '{s+=$1} END{print s/NR}' /tmp/lat.sorted)" \
    '$1>m{c++} END{printf "\nrequests slower than the mean: %d of %d (%.1f%%)\n", c, NR, 100*c/NR}' \
    /tmp/lat.sorted
Expected result — click to reveal
plain text
mean      20.6 ms
p50        9.9 ms
p90       14.0 ms
p95       14.6 ms
p99       15.0 ms
p99.9   1115.1 ms

requests slower than the mean: 10 of 1000 (1.0%)

What to read out of this. Look at the mean and the p99 together: 20.6 ms and 15.0 ms.

The mean is higher than the p99. That is not a mistake and it is not unusual — it is what happens whenever a small number of requests are enormously slower than the rest, which is the normal shape of real latency. The mean is being dragged upward by ten requests out of a thousand, and it now describes 1% of your traffic. Ninety-nine percent of users had a better experience than "average".

And the mean hides the thing that matters. The disaster in this data is p99.9 = 1115.1 ms — someone waited over a second. (Note that p99.9 of a thousand samples is rank 999, not rank 1000; rank 1000 is the maximum. With this few requests the tail rests on a single data point, which is itself worth remembering.) A dashboard showing "average latency 20 ms, well within SLO" is green while that is happening, every minute of every day.

p50 = 9.9 ms is the typical experience. The gap between p50 and p99.9 — a factor of over a hundred — is the number worth tracking, because it is a measure of how consistent the service is, and consistency is what people actually notice.

Read the percentiles as a shape, not as four numbers. p50 to p99 barely moves (10 → 15 ms): the fast path is tight and healthy. Then p99 to p99.9 explodes. A flat body with a cliff at the tail means a distinct, occasional event — a garbage-collection pause, a cache miss going to disk, a lock, a retry. A gradually rising curve with no cliff means the whole system is loaded. The two have completely different fixes, and the mean tells you neither.

Your exact numbers will differ, because mawk and gawk use different random number generators from the same seed. These figures are from mawk 1.3.4, which is /usr/bin/awk on Debian and Ubuntu; gawk gives a mean of 19.8 and a p99.9 of 1190.0. The shape — mean above p99, exactly ten requests above the mean, a hundredfold gap from p50 to p99.9 — holds on both, and the shape is the point.

Now imagine this at 500 hosts. Store latency as a histogram, not as pre-computed percentiles, because histograms can be added across hosts and percentiles cannot. Once every host emits its own p99, the fleet p99 is permanently unrecoverable — and the number your dashboard shows instead (the mean of the p99s) is a quantity with no meaning at all. This is the most expensive metrics mistake to fix later, because it needs a change to every emitter.

A4 · Latency, throughput and the reason 80% is not "20% headroom"

Two numbers get used interchangeably and are not the same thing. Throughput is work per unit time — requests per second, MB/s, transactions per minute. Latency is how long one piece of work takes. A system can improve one while destroying the other, and batching does exactly that on purpose.

Little's Law ties them together, and it is the one piece of queueing theory worth memorising:

L = λW — the average number of items in the system equals the arrival rate times the average time each spends there.

In working terms: in-flight requests = throughput × latency. It holds for any stable system, with no assumptions about the arrival pattern or the service-time distribution, which is why it is so widely useful.

Two things it settles immediately. Sizing a pool: 1,200 requests per second at 45 ms average latency means 54 requests in flight, so a thread or connection pool of 20 is a bottleneck you built yourself. Finding a ceiling: 200 workers at 100 ms each is 2,000 requests per second and not one more — beyond that, arrivals simply queue, and latency rises to absorb them.

The detail that separates candidates: saying that Little's Law is descriptive, not predictive, because λ and W are not independent. As utilization approaches 1, W rises without bound. You cannot plug in "the throughput we want" and read off a latency; you can only describe a system you have measured.

That last point is the practical one, and it has a shape:

UtilizationLatency multiplier — 1/(1−u)What it feels like
50%2.0×Fine
70%3.3×Still fine
80%5.0×Noticeably slower; alerts start firing
90%10.0×Bad, and one deploy away from worse
95%20.0×Cascading timeouts
99%100.0×Effectively down
The counter-intuitive part, and the sentence to take into an interview: going from 80% to 90% utilization does not cost you 10% of anything — it doubles your latency.

Utilization is linear; the queue it produces is not. 1/(1−u) is a hyperbola, and every capacity conversation that treats "we're at 80%" as "we have 20% left" is reading a straight line onto a curve. The usable headroom is gone long before the gauge is.

This is also why saturation belongs in both USE and the Golden Signals. Saturation is where you feel the curve; utilization is where you feel the line.

Real-world analogy — the motorway at rush hour

A motorway lane carries about the same number of cars per hour at 60% occupancy and at 85%. Throughput barely changes. Journey time does not behave that way at all: the road that took twenty minutes at 60% takes forty-five at 85%, and at 95% it stops being a road and becomes a car park.

Every driver on it has the same experience of the transition: nothing, nothing, nothing, and then suddenly everything. There is no warning band, because the curve has no straight section near the end.

Little's Law is the traffic engineer's version of the same fact: cars on the road = cars per hour × journey time. If a thousand cars an hour are on the road for half an hour each, there are five hundred cars on it right now. Want to know whether adding a slip road helps? Measure two of the three and the third is fixed.

And the reason "we're at 85%, we have 15% headroom" is such a comfortable lie: the gauge on the wall is occupancy, and the thing people complain about is journey time. They are not the same curve.

Where the analogy stops working. Cars do not retry. When a request times out and is retried, the retries add traffic exactly when the road is fullest, so a real system does not merely slow down at the knee — it can collapse. That is why retry budgets and load shedding exist, and it has no motorway equivalent.

🧪 Exercise A4.1 — Size a pool, then find the knee
bash
# Little's Law: how many requests are in flight?
awk 'BEGIN{ lam=1200; w=0.045;
  printf "throughput %.0f req/s, latency %.0f ms -> in flight L = %.1f\n", lam, w*1000, lam*w }'

# The other direction: what is the ceiling of a fixed pool?
awk 'BEGIN{ L=200; w=0.100;
  printf "%d workers at %.0f ms each -> ceiling = %.0f req/s\n", L, w*1000, L/w }'

# And the shape of the queueing curve
awk 'BEGIN{ printf "%-8s %s\n", "util", "latency multiplier 1/(1-u)";
  split("0.50 0.70 0.80 0.90 0.95 0.99", a, " ");
  for (i=1;i<=6;i++) { u=a[i]+0; printf "%-8.0f %8.1fx\n", u*100, 1/(1-u) } }'
Expected result — click to reveal
plain text
throughput 1200 req/s, latency 45 ms -> in flight L = 54.0
200 workers at 100 ms each -> ceiling = 2000 req/s
util     latency multiplier 1/(1-u)
50            2.0x
70            3.3x
80            5.0x
90           10.0x
95           20.0x
99          100.0x

What to read out of this.

54 in flight is the number that settles arguments about pool sizes. If the connection pool is 20, the service cannot possibly be doing 1,200 req/s at 45 ms — either the latency is higher than reported, or requests are queueing before they reach the pool where your latency metric never sees them. That second case is extremely common and extremely hard to see: the request has arrived, the clock is running, and your instrumentation has not started yet.

The 2,000 req/s ceiling is the same arithmetic run backwards, and it is how you answer "will this handle Black Friday?" without a load test. Note what it means: past 2,000, throughput stops rising and latency absorbs the difference. The graph does not fall over — it goes flat while the latency graph climbs, which is why "throughput looks fine" is not evidence of health.

The multiplier column is the important half. The ten-point step from 70% to 80% adds 1.7 to the multiplier (3.3× → 5.0×). The very next ten-point step, 80% to 90%, adds 5.0 (5.0× → 10.0×). Same step on the gauge, three times the damage — and every capacity plan built on "keep it under 80%" is really a plan to stay on the flat part of that curve.

These are idealised M/M/1 figures, and a real system's curve is different in detail — more servers push the knee later, variable service times pull it earlier. The shape is universal, and the shape is the lesson.

A5 · Interview questions — methodology

Q. How would you troubleshoot a server that is running slow in Linux?

Resist naming a tool. The answer that lands is a method, described in three moves.

First, define "slow" and scope it. Slow for whom, since when, and is it every request or a fraction? Is it this host or the whole fleet? A single host in a load-balanced pool that is slow is a completely different investigation from all of them being slow, and it costs one question to find out.

Second, run a fixed checklist rather than a favourite command. uptime for the trend, vmstat 1 and mpstat -P ALL 1 for where the time goes, free -m, iostat -xz 1, sar -n DEV 1, pidstat 1, dmesg | tail. Sixty seconds, and it eliminates whole categories: CPU-bound versus I/O-bound versus memory-pressured versus not-the-host-at-all.

Third, go where the checklist points, and only then reach for perf or a tracer.

The details that separate candidates: naming the method — the USE method, one question each for utilization, saturation and errors across CPU, memory, disk and network — and saying what happens when it comes back clean. That is not a dead end; it is the finding that moves you from the host to the request path, where you look at RED or the Golden Signals instead. Adding "and I check %steal first on a VM, because if the hypervisor is taking the CPU, nothing I do on this box matters" is a strong, concrete finish.

Q. The load average is high, but CPU and memory look normal. What is going on?

This is the classic, and the answer is a fact about Linux specifically: the run queue that feeds the load average includes uninterruptible-sleep (D state) tasks, not just runnable ones. On other UNIXes, load average is a CPU-demand metric. On Linux it is a "tasks that want to be making progress" metric, and a process blocked on disk or on an NFS server that has gone away counts.

So high load with idle CPU almost always means processes are blocked, not computing. Find them: ps -eo state,pid,comm | awk '$1 ~ /^D/', and then cat /proc/<pid>/stack or /proc/<pid>/wchan to see what they are blocked in.

The usual causes are a slow or failing disk, an NFS or network filesystem timing out, or heavy writeback flushing. iostat -xz 1 and the b column of vmstat confirm it in seconds.

The details that separate candidates: pointing out that iowait is not a fifth kind of busy — it is a subset of idle, time when the CPU had nothing to run and at least one task was waiting on I/O. So low %iowait does not rule out an I/O problem: if there is other work to run, the CPU is counted busy instead. Then adding the diagnostic that beats both: /proc/pressure/io, which measures stall directly. And knowing that a load average of 8 on a 64-core machine is nothing at all, so the number is meaningless without nproc.

Q. What are the four golden signals?

Latency, Traffic, Errors, Saturation — from chapter 6 of Google's Site Reliability Engineering.

Latency is how long a request takes; traffic is how much demand there is; errors is the rate of failing requests, including the implicit kind (an HTTP 200 with wrong content) and the policy kind (over a second when your SLO says under a second); saturation is how full the service is, focusing on whichever resource is most constrained.

RED — Rate, Errors, Duration — is the same idea minus saturation, named at Weaveworks by Tom Wilkie, and it maps neatly onto per-service dashboards.

The details that separate candidates: two things. First, that latency must be split into successful and failed requests, because fast failures make the latency graph look better while the service is broken — that is straight out of the SRE book and almost nobody says it. Second, that Golden Signals and RED are request-oriented and USE is resource-oriented, and you want both: one tells you users are suffering, the other tells you which resource to fix. Being able to say "RED is the Golden Signals minus saturation, and that is exactly the gap USE fills" shows you understand them as a system rather than as two lists you memorised.

Q. The p99 just jumped 5×. Walk me through it.

First establish that it is real and scope it. Is the request count stable? A p99 computed over eleven requests is noise. Is it one endpoint, one host, one customer, one availability zone — or everything? Slicing by those four dimensions usually ends the investigation before any tool comes out, because a jump on one host is a host problem and a jump on one endpoint is a code or query problem.

Then look at the shape. Did p50 move too? p50 flat with p99 up means an occasional discrete event: a garbage-collection pause, a cold cache going to disk, a lock, a retry after a timeout. p50 up as well means the whole system is loaded, and the question becomes which resource.

Then correlate with what changed. Deploy, config push, feature flag, traffic increase, a dependency's own latency, or a cron job that starts at the same minute every day. Overlay the p99 graph on the deploy markers first — it is free and it is right surprisingly often.

Then go to the host with the USE checklist, and only then to a profiler.

The details that separate candidates: mentioning the queueing knee — if utilization crept from 80% to 90%, the tail doubling is not a bug, it is 1/(1−u), and the fix is capacity rather than code. Mentioning retry amplification, where a small latency rise triggers client timeouts and retries that add load exactly when there is none to spare. And the metrics-hygiene point: if your p99 is computed by averaging per-host p99s, the number is meaningless and the first fix is to store histograms.

Q. How do you approach capacity planning?

Start from demand, not from servers. Get a traffic forecast with a unit that means something to the business — orders per second, active users, messages per minute — and its growth rate and seasonality, including the one annual peak everyone forgets.

Then find the actual ceiling by measurement, not by guess: load-test a single instance to saturation and record where latency leaves the flat part of the curve. That is your usable capacity per instance, and it is nearly always well below 100% CPU.

Then apply Little's Law to size the pieces: in-flight = throughput × latency tells you thread pools, connection pools and queue depths, and shows you where a limit you set years ago has become the bottleneck.

Then add headroom for the failure you plan to survive — losing one zone of three means each survivor must carry 50% more — and set an alert on the leading indicator, not on the gauge.

The details that separate candidates: refusing the "we're at 80%, so we have 20% headroom" framing out loud, and explaining why with the 1/(1−u) curve. Also separating vertical from horizontal: vertical is quick, bounded and does nothing for availability; horizontal is the real answer but only if the workload has no single-instance state and the downstream — usually the database — can take the extra connections. Finishing with "and I plan for the constraint I measured, which is usually not CPU" is a good, honest note: it is far more often a connection limit, a lock, or a single-threaded section.


⏱️ Part B · The first sixty seconds

B1 · Ten commands, and what each one eliminates

Netflix's performance team published a checklist in 2015 that has been quoted ever since, and it earns the reputation: ten commands, sixty seconds, and you have eliminated most of the possibilities. Learn them in this order, and learn what each one rules out rather than what it prints.

#CommandThe question it answers
1uptimeIs this new, and is it getting worse? Three numbers, one trend
2dmesg | tailDid the kernel already tell us? OOM kills, I/O errors, dropped links
3vmstat 1Which resource? Run queue, swapping, block I/O, user vs system time
4mpstat -P ALL 1Every CPU, or one hot CPU? And %steal on a VM
5pidstat 1Which process? Rolling, unlike top, so it survives being scrolled past
6iostat -xz 1Is the disk busy, and is it slow? Latency, queue, throughput
7free -mIs memory actually short? Read available
8sar -n DEV 1Is the network near line rate?
9sar -n TCP,ETCP 1Retransmits, failed connects, resets — the network being unwell
10topA final sanity check against everything above
Why this order, which is the part usually left out. It goes broad to narrow, and cheap to expensive. Commands 1 and 2 cost nothing and can end the investigation outright — an OOM kill in dmesg is the whole answer. Command 3 splits the world into four categories. Command 4 splits one of those in half. Only at command 5 do you name a process, and by then you have already ruled out the alternatives rather than assumed them.

The habit most people have is to jump straight to the last command — top — which names a process before establishing there is a CPU problem at all. When the real cause is a saturated disk, the busiest process in top is an innocent bystander, and hours get spent optimising it.

Real-world analogy — triage in an emergency department

Nobody walking into an emergency department goes straight to an MRI scanner. The nurse takes pulse, blood pressure, temperature, oxygen saturation — four cheap measurements, sixty seconds, no equipment worth mentioning. Those numbers rarely give a diagnosis. What they do is decide which specialist and which machine, and rule out the emergencies that would kill you in the queue.

The ten commands are the vitals. uptime is the pulse: one number, no diagnosis, and an instant sense of whether things are getting worse. dmesg is asking the patient what happened, and is answered surprisingly often. vmstat is the blood pressure — one reading covering several systems at once.

And the ordering is the same principle. Vitals before scans, because a scan of the wrong organ costs an hour and answers nothing, while the vitals cost a minute and tell you which organ.

Where the analogy stops working. A patient has one body with a fixed set of organs. Your system has a new service in it every quarter, and the checklist covers the host only — which is why it ends by telling you to leave the host, not by giving you an answer.

🧪 Exercise B1.1 — Run the whole checklist against a load you created
bash
# Something to find: two CPU burners
for i in 1 2; do ( timeout 25 bash -c 'x=0; while :; do x=$((x+1)); done' & ); done
sleep 3

uptime                                    # 1
dmesg 2>/dev/null | tail -3               # 2
vmstat 1 3                                # 3
mpstat -P ALL 1 1 | tail -4               # 4
pidstat 1 1 | tail -6                     # 5
iostat -xz 1 2 | tail -8                  # 6
free -m                                   # 7
sar -n DEV 1 1 | tail -3                  # 8
sar -n TCP,ETCP 1 1 | tail -3             # 9
top -bn1 | head -7                        # 10
Expected result — click to reveal (abridged)
plain text
 12:02:38 up  4:54,  0 user,  load average: 0.38, 0.23, 0.19

[14001.537519] veth485ad0a (unregistering): left promiscuous mode
[14001.538750] docker0: port 1(veth485ad0a) entered disabled state

procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st gu
 2  0      0 5796084  20376 1754580    0    0   169   100  992    7  2  3 96  0  0  0
 2  0      0 5796780  20376 1754600    0    0     0     0  922  955 97  3  0  0  0  0
 2  0      0 5796840  20376 1754600    0    0     0     0  656  755 97  3  0  0  0  0

Average:     all   96.48    0.00    3.02    0.00    0.00    0.00    0.00    0.00    0.00    0.50
Average:       0   98.99    0.00    1.01    0.00    0.00    0.00    0.00    0.00    0.00    0.00
Average:       1   94.00    0.00    5.00    0.00    0.00    0.00    0.00    0.00    0.00    1.00

Average:      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  Command
Average:        0         1    0.00    2.00    0.00    0.00    2.00     -  process_api
Average:        0     27896   99.00    1.00    0.00    0.00  100.00     -  bash
Average:        0     27901   98.00    2.00    0.00    0.00  100.00     -  bash

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          96.98    0.00    3.02    0.00    0.00    0.00

Device            r/s     rkB/s   rrqm/s  %rrqm r_await rareq-sz     w/s     wkB/s ...

               total        used        free      shared  buff/cache   available
Mem:            8023         852        5686           4        1733        7170
Swap:              0           0           0

Average:         eth0      8.00      8.00      1.20      2.03      0.00      0.00      0.00      0.00

Average:     atmptf/s  estres/s retrans/s isegerr/s   orsts/s
Average:         0.00      0.00      0.00      0.00      0.00

What to read out of this, command by command — the value is in what each one removes.

1. uptime1.42, 0.71, 0.35, rising steeply. Something started recently. If those numbers had been falling you would already be looking at the aftermath of an event, which is a very different conversation.

2. dmesg — the last lines are Docker tearing down a veth pair. Unrelated, and that is the point: you are looking for Out of memory: Killed process, I/O error, blocked for more than 120 seconds, or a link flapping. None present, so a whole class of hardware and OOM causes is eliminated for free.

3. vmstat 1skip the first line, always: it is an average since boot, not a sample. On the real samples, r=2 on a 2-CPU box, b=0, si/so=0, us=97, sy=3, wa=0. That is four eliminations in one screen: no swapping, nothing blocked, no I/O wait, and the work is in user space, not the kernel. If sy had been 97, you would be looking at system calls or interrupts and the rest of this module would take a different path.

4. mpstat -P ALL — both CPUs near 100%, so the load is spread. One CPU pinned with the rest idle would mean a single-threaded bottleneck, and adding cores would not have helped. %steal is 0.00, so the hypervisor is not the problem.

5. pidstat 1 — two bash processes at 100% each. Now, and only now, you have a suspect. Note pidstat rolls, printing a fresh block each interval, so it captures a short-lived spike that top would have redrawn away.

6. iostat -xz — the CPU line agrees with mpstat (near-zero idle), and there are no device rows at all, because -z omits devices with no activity in the interval. The disk is doing nothing. Confirms wa=0.

7. free -mavailable is 7170 of 8023 MB. Plenty. Read available, never free.

8 and 9. sar -n — 8 packets/s and zero retransmits, zero failed connects, zero resets. The network is idle and healthy. Command 9 is the one people skip, and it is the one that catches a sick network as opposed to a busy one.

The conclusion after sixty seconds: user-space CPU work, spread over both cores, from two named processes, with disk, memory, network and hypervisor all ruled out. That is not a guess about where to look next — it is the only place left, and Part C is how you look there.

Now imagine this at 500 hosts. Wrap those ten commands in one script, have your incident tooling run it on the affected host automatically, and attach the output to the ticket before a human opens it. The single most expensive thing in an incident is the twenty minutes between "it's slow" and "someone SSH'd in and ran vmstat" — and by the time they do, the spike is often over. Also keep sar collection enabled (ENABLED="true" in /etc/default/sysstat on Debian/Ubuntu): it is the only one of these that can answer "what did this look like at 03:00 last Tuesday?"

B2 · The numbers that lie

Official docs: proc(5) · mpstat(1) · vmstat(8) · PSI

Every one of these is a number people quote confidently in incidents, and every one of them means something different from what it appears to mean. Most of them you have already met in earlier modules; here they are in one place, because in an interview they arrive together.

The numberWhat people think it meansWhat it actually means
Load averageCPU demandRunnable plus uninterruptible-sleep tasks. A blocked-on-disk process counts. Meaningless without nproc (Module 07)
%iowaitTime lost to slow disksA subset of idle — the CPU had nothing else to run and something was waiting on I/O. Busy CPU hides it completely (Module 07)
%util in iostatHow full the disk isPercentage of time at least one request was in flight. On an NVMe serving 32 in parallel, 100% can mean 3% loaded (Module 10)
free in free -mMemory leftMemory doing nothing. The number you want is available, which includes reclaimable page cache (Module 09)
First line of vmstat and iostatThe current sampleAn average since boot for the rate columns. sar, mpstat and pidstat do not do this — their first line is a real sample
Mean latencyThe typical requestA number a small tail can drag anywhere. Frequently higher than the p99 (A3)
%stealRarely looked atYour vCPU was runnable and the hypervisor ran someone else. Nothing you change on this box fixes it
CPU % inside a containerHow busy the container isUsually a percentage of the host's CPUs, not of the cgroup quota. Check nr_throttled in cpu.stat instead (Module 12)
Interview-grade detail — the %steal triangle. Sustained %steal has three quite different causes and three different fixes, and naming them is a strong signal. Noisy neighbours on an oversubscribed host: move instance, or move to a dedicated tenancy. Burstable instance credit exhaustion (AWS T-series and equivalents): the instance is being throttled to its baseline, and the fix is an instance family change, not tuning. Your own overcommitted hypervisor, if you run the virtualisation. All three look identical from inside the guest — which is the point worth making: %steal is the one performance number you cannot act on from within the machine.
Diagram source
flowchart TD
  A["Service is slow"] --> B{"%steal high?"}
  B -->|"Yes"| Z["Hypervisor.<br>Nothing on this box helps"]
  B -->|"No"| C{"vmstat: r > nproc?"}
  C -->|"Yes"| D{"us or sy?"}
  D -->|"us"| D1["Application CPU.<br>Profile it - Part C"]
  D -->|"sy"| D2["Syscalls or interrupts.<br>Trace it - Part D"]
  C -->|"No"| E{"b > 0, or D-state<br>processes?"}
  E -->|"Yes"| F["Blocked on I/O.<br>iostat -xz, pressure/io"]
  E -->|"No"| G{"si / so > 0?"}
  G -->|"Yes"| H["Swapping.<br>Module 09"]
  G -->|"No"| I["Host is fine.<br>Look at the request path"]
Mermaid diagrams do not render until you switch the block to Preview. Click the code block, then use the Preview / Split control at its top right. The Notion API cannot set that mode, so it arrives as code.
Real-world analogy — the dashboard warning lights

A car's temperature gauge reads the coolant, not the engine. On a car that has lost all its coolant the gauge reads cold, because there is nothing hot flowing past the sensor — and the driver watching a comfortable needle drives on until the engine seizes. The gauge is not broken and it is not lying. It is answering a narrower question than the one being asked of it.

Every number in the table above is that gauge. %iowait genuinely measures "idle time with I/O outstanding". It is only wrong when someone reads it as "how much the disk is hurting us", because a busy CPU means there is no idle time for it to be counted in — the coolant has drained out of the sensor.

The habit that protects you is a small one: for each number on your dashboard, be able to say the exact sentence the kernel would use. If you cannot, you do not know what it will read during the failure you have not had yet.

Where the analogy stops working. A car has a handful of gauges chosen by one manufacturer. Your dashboards were assembled over years by different people with different assumptions, and nobody wrote down what any of them meant.

🧪 Exercise B2.1 — Catch three of them in the act
bash
# 1. The first line is not a sample. Compare it with the real ones.
echo "--- vmstat: line 1 is an average SINCE BOOT ---"
vmstat 1 3

# 2. A load average is meaningless without the core count
awk -v n="$(nproc)" '{printf "load %s over %d CPUs = %.2f per CPU\n", $1, n, $1/n}' /proc/loadavg

# 3. Who is in uninterruptible sleep right now? These count towards load.
ps -eo state,pid,comm | awk '$1 ~ /^D/'
echo "processes in D state: $(ps -eo state | grep -c '^D')"

# 4. Where does disk work actually show up on YOUR storage?
echo "--- 1.2 GB write, CPU otherwise idle ---"
( dd if=/dev/zero of=/tmp/iotest bs=1M count=1200 conv=fdatasync 2>/dev/null & )
mpstat 1 3 | tail -4
rm -f /tmp/iotest
Expected result — click to reveal
plain text
--- vmstat: line 1 is an average SINCE BOOT ---
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st gu
 0  0      0 5796084  20376 1754580    0    0   169   100  992    7  2  3 96  0  0  0
 0  0      0 5796780  20376 1754600    0    0     0     0  312  455  1  4 95  0  0  0
 0  0      0 5796840  20376 1754600    0    0     0     0  289  401  1  3 96  0  0  0

load 0.14 over 2 CPUs = 0.07 per CPU

processes in D state: 0

--- 1.2 GB write, CPU otherwise idle ---
12:04:52     all    1.03    0.00   49.48    0.00    0.00    0.00    0.00    0.00    0.00   49.48
12:04:53     all    0.51    0.00   52.04    0.00    0.00    0.51    0.00    0.00    0.00   46.94
12:04:54     all    1.02    0.00   52.04    0.00    0.00    1.02    0.00    0.00    0.00   45.92
Average:     all    0.85    0.00   51.19    0.00    0.00    0.51    0.00    0.00    0.00   47.44

What to read out of this.

Block 1: look at bi, bo and cs. The first line says 169 and 100 blocks and only 7 context switches; the real samples say 0, 0 and several hundred. Those columns are averages over the machine's entire uptime — hours of history compressed into one row that looks exactly like a measurement. Note that only the rate columns are affected: r, b and the memory columns on that first line are current values. Discard it every single time, in vmstat and in iostat. sar, mpstat and pidstat are exempt — their first interval is a genuine sample. Whole incident calls have been spent on this one.

Block 2: 0.07 per CPU. The raw load average is the number people quote and it carries no information on its own. A load of 8 is an emergency on a 2-core box and idle on a 64-core one; divide before you react.

Block 3: zero processes in D state. This is the command to run when load is high and CPU is idle — D is uninterruptible sleep, it counts towards the load average on Linux, and it is almost always disk or a network filesystem. When you find one, cat /proc/<pid>/wchan names the kernel function it is stuck in.

Block 4 is the surprise, and it is real. A 1.2 GB write with an idle CPU produced %iowait of 0.00 and %sys of about 52 — the write went through virtualised storage, so the cost appeared as kernel CPU time, not as waiting. On this class of machine %iowait never rises at all.

That generalises further than it looks. %iowait is already a subset of idle, so a busy CPU hides it — and on virtio, overlay, network-backed or NVMe storage it can stay near zero even on a completely idle CPU. Never conclude "no disk problem" from a low %iowait. Use iostat -xz's r_await/w_await and aqu-sz, or /proc/pressure/io where PSI exists. Your own numbers here will differ by storage type, and finding out which shape your platform has is the whole reason to run it.

Now imagine this at 500 hosts. Audit your dashboards against that table once. In most organisations you will find at least one alert on free rather than available, at least one capacity dashboard built on %util for NVMe, and at least one latency panel showing a mean. Each of those is a page that will fire at the wrong time or stay silent at the wrong time, and each is a ten-minute fix. Write the kernel's own sentence into the panel description while you are there, so the next person does not have to rediscover it.

B3 · Interview questions — reading the numbers

Q. How do you check for excessive I/O wait, and what does a low %iowait prove?

vmstat 1 (the wa column and the b column), mpstat -P ALL 1 (%iowait per CPU), and iostat -xz 1 for the per-device picture — where the numbers that matter are r_await and w_await (average latency in milliseconds) and aqu-sz (average queue depth), not %util.

A low %iowait proves almost nothing, and this is the real answer. It is defined as time the CPU was idle with at least one I/O outstanding. If the CPU has other work, that time is counted as busy instead, and %iowait stays near zero while the disk is the bottleneck. It can also stay near zero on virtualised, overlay or network-backed storage, where the cost shows up as system time instead.

The reliable signals are per-device latency from iostat, the count of processes in D state, and — best of all — /proc/pressure/io, which measures stall directly rather than inferring it from idleness.

The details that separate candidates: saying that %util is meaningless on any device that serves requests in parallel. It measures the fraction of time at least one request was in flight, so an NVMe drive handling 32 concurrent requests can show 100% while at a few percent of its real capacity. Knowing that svctm was removed from sysstat in 12.1.2, and that avgqu-sz had already been renamed aqu-sz a release or two earlier, is a nice touch, because it shows you have read the current tool rather than a 2014 blog post.

Q. top shows 100% CPU. Is that a problem?

Not on its own — and answering "yes" is the trap. A batch job, a compile, or a video encode should use 100% CPU; that is the resource being used, which is what you bought it for. Utilization is not a problem. Saturation is.

The follow-up questions are: is the run queue longer than nproc (vmstat 1, column r)? Is /proc/pressure/cpu non-trivial? Is latency actually worse? If the answer to all three is no, the machine is being used efficiently and there is nothing to fix.

And two clarifying checks: mpstat -P ALL 1, because one pinned CPU with the rest idle is a single-threaded bottleneck rather than a busy machine; and %steal, because on a VM the CPU may not really be yours.

The details that separate candidates: raising the container case unprompted. Inside a container, top usually reports a percentage of the host's CPUs, not of the cgroup quota — so a container limited to 0.5 CPU can show 50% and be completely throttled. The number that tells the truth is nr_throttled and throttled_usec in cpu.stat. Being throttled at 40% reported CPU is one of the most common and most confusing production symptoms there is.

Q. What is %steal, and what do you do about it?

Time your virtual CPU was runnable but not scheduled, because the hypervisor gave the physical core to someone else. It is involuntary and it is invisible to the application, which simply experiences everything taking longer.

Three causes, three different fixes: a noisy neighbour on an oversubscribed host (move, or pay for dedicated tenancy); burstable-instance credit exhaustion on T-series-style instances, where you are being throttled to a baseline (change instance family, not code); or your own hypervisor being overcommitted, if you run it.

The details that separate candidates: stating plainly that %steal is the one performance number you cannot act on from inside the machine — no amount of profiling, tuning or optimising changes it — so checking it early prevents an entire wasted investigation. And knowing that it appears in mpstat, vmstat (st), top and sar -u, so there is no excuse for not looking; most people simply have never been told what the column is.


🔬 Part C · Profiling with perf

C1 · perf stat — counting, before sampling

The checklist told you which resource. perf tells you which code. It has two modes and the difference matters: counting (perf stat) adds up events over a whole run with almost no overhead, and sampling (perf record) interrupts periodically and records a stack. Always count first — it is cheaper, and it usually decides which question to ask next.

perf understands three kinds of event:

Event kindExamplesWhere it comes from
Softwaretask-clock, context-switches, cpu-migrations, page-faults, minor-faults, major-faultsThe kernel counts them itself. Available wherever perf_event_open is — which is everywhere except a locked-down container (Section C3)
Hardware (PMU)cycles, instructions, cache-misses, branch-missesThe CPU's performance monitoring unit. Often unavailable in a VM
Tracepointssched:sched_switch, block:block_rq_issue, syscalls:sys_enter_openatStatic hooks compiled into the kernel. perf list shows hundreds
The counter-intuitive part: on most cloud VMs, the famous perf counters do not work. cycles and instructions need a hardware PMU, and most hypervisors do not expose a virtual one. You get <not supported> — and you will see exactly that in the exercise below.

This matters more than it sounds, because most perf tutorials on the internet were written on bare metal and open with perf stat showing IPC and cache misses. On the machine you actually have, the useful half of perf stat is the software events: task-clock, context switches, migrations and page faults. Those are always there, they are enough to characterise most problems, and knowing to reach for them instead of concluding "perf is broken" is a real differentiator.

Interview-grade detail — <not supported> versus <not counted>. They are different failures and perf distinguishes them deliberately.

<not supported> means the kernel or hardware does not provide that event at all — the PMU is absent, which is the cloud-VM case above.

<not counted> means the event exists but produced no data in this measurement — commonly because perf was multiplexing more events than there are hardware counters and this one did not get a slot, or because it genuinely never fired.

The practical consequence: <not counted> is often fixed by asking for fewer events at once. <not supported> never is.

Real-world analogy — the till roll and the security camera

A shop owner wants to know why Tuesdays are slow. There are two instruments.

The till roll counts: how many transactions, how many refunds, how many card declines, total takings. It runs all day, costs nothing, and is complete — no transaction escapes it. What it cannot tell you is what the queue was doing at 2:15.

The security camera samples: a frame every so often, which you review afterwards. It shows you the shape of the day — where people stood, which aisle was blocked — but it is a sample, so a customer who walked in and out between two frames is invisible. It also costs storage and someone's afternoon.

perf stat is the till roll. perf record is the camera. You look at the till roll first, because "there were four hundred refunds today" ends the investigation for free, and because it tells you which hour of camera footage is worth watching.

And the PMU problem has a shop version: this branch was fitted with a till that counts transactions but not items, because head office bought the cheap model. You can still run the shop. You just cannot answer the item-level questions, and no amount of pressing buttons will change that.

Where the analogy stops working. A camera records everything in frame; a profiler only samples threads that are on CPU. A thread blocked waiting for a disk is invisible to a standard CPU profile — which is why "the flame graph looked fine" is such a common and misleading statement.

🧪 Exercise C1.1 — Count first, and find out what your machine can measure
bash
# perf may be installed as a versioned binary. If `perf` is not on PATH:
#   ls /usr/lib/linux-tools/*/perf
# and use that path directly, or install linux-tools-generic.

# 1. What CAN this machine count?
perf list 2>/dev/null | grep -c 'Tracepoint event'
perf list 2>/dev/null | sed -n '/Software event/!d;p' | head -12

# 2. Count a trivial command, asking for hardware events too
perf stat -e task-clock,context-switches,page-faults,cycles,instructions -- sleep 0.2

# 3. Count a real CPU-bound workload, software events only
perf stat -e task-clock,context-switches,cpu-migrations,page-faults \
  -- timeout 2 bash -c 'x=0; while :; do x=$((x+1)); done'
Expected result — click to reveal (includes a deliberate failure)
plain text
747
  alignment-faults                                   [Software event]
  bpf-output                                         [Software event]
  cgroup-switches                                    [Software event]
  context-switches OR cs                             [Software event]
  cpu-clock                                          [Software event]
  cpu-migrations OR migrations                       [Software event]

 Performance counter stats for 'sleep 0.2':

              0.98 msec task-clock                       #    0.005 CPUs utilized
                 1      context-switches                 #    1.024 K/sec
                63      page-faults                      #   64.538 K/sec
   <not supported>      cycles
   <not supported>      instructions

       0.202089319 seconds time elapsed

       0.001388000 seconds user
       0.000000000 seconds sys

 Performance counter stats for 'timeout 2 bash -c x=0; while :; do x=$((x+1)); done':

           1975.52 msec task-clock                       #    0.987 CPUs utilized
                71      context-switches                 #   35.940 /sec
                 2      cpu-migrations                   #    1.012 /sec
               224      page-faults                      #  113.388 /sec

       2.002121914 seconds time elapsed

       1.975273000 seconds user
       0.000000000 seconds sys

What to read out of this.

<not supported> on cycles and instructions is the deliberate failure, and it is the normal state of a cloud VM. No virtual PMU is exposed, so the hardware counters simply are not there. Nothing is misconfigured and no permission will fix it. Every "measure your IPC" tutorial stops working right here, and knowing that in advance saves an hour.

task-clock versus seconds time elapsed is the single most useful ratio in perf stat. 1975.52 msec of CPU across 2.002 seconds of wall clock is 0.987 CPUs utilized — the process was on CPU essentially the whole time it existed. That is a CPU-bound workload, and a CPU profile will be informative.

Compare sleep 0.2: 0.98 msec of CPU across 0.202 seconds is 0.005 CPUs utilized. That process spent 99.5% of its life off CPU. Profiling it for CPU time would produce an empty and completely honest flame graph, and the real question would be what it was waiting for.

Read that ratio before you profile anything. Near 1.0 per thread means CPU-bound, so sample the CPU. Near 0 means blocked, and the answer is in Part D, not in a flame graph.

user versus sys narrows it further. Here it is 1.975 user and 0.000 system: pure user-space computation, no kernel involvement. Had sys dominated, the next tool would be a syscall tracer rather than a profiler.

71 context switches and 2 migrations over two seconds is nothing. Thousands per second would suggest lock contention or an over-threaded application, and perf stat is the cheapest way to spot it.

Now imagine this at 500 hosts. perf stat -a -I 1000 samples the whole machine on an interval and is cheap enough to leave running for a minute during an incident. But the highest-value use of perf stat at scale is in CI: count task-clock, page-faults and context-switches for a benchmark on every merge and alert on the ratio changing. It catches the performance regression in the commit that introduced it, rather than three months later when someone notices the p99.

C2 · perf record and flame graphs

Counting told you the workload is CPU-bound. Sampling tells you which functions. perf record interrupts at a fixed frequency, captures the call stack at that instant, and repeats — so a function that is on CPU 40% of the time appears in roughly 40% of the samples. That is the whole idea, and everything else is presentation.

bash
perf record -F 99 -a -g -- sleep 30

Four things, and each is a decision:

-F 9999 samples per second, per CPU. 99, not 100, deliberately — an odd frequency avoids landing in lockstep with anything that runs on a round-number timer
-aAll CPUs, system-wide. Drop it and pass a command or -p PID to profile one thing
-gRecord the call stack, not just the leaf function. Without it you learn that time is in memcpy and nothing about who called it
-- sleep 30The duration. Profiling is sampling; 30 seconds of a steady workload is far more useful than 2

Then the flame graph, which is three more commands:

bash
perf script -i perf.data > out.perf                     # decode to text
./stackcollapse-perf.pl out.perf > out.folded           # one line per unique stack
./flamegraph.pl out.folded > cpu.svg                    # draw
How to read a flame graph, which almost nobody explains properly.

The x-axis is not time. It is alphabetically sorted stacks, merged. A frame's width is the fraction of samples it appeared in — that is the only quantity in the picture. Left-to-right means nothing at all, and reading it as a timeline is the single most common mistake.

The y-axis is stack depth: callers below, callees above. So you read it top-down for cost and bottom-up for cause — the widest box at the top is where the CPU actually was; follow it downwards to find out who is responsible for it being there.

Plateaus are the finding. A wide flat box near the top means a lot of time in one function that calls nothing else. A tall thin spike is a deep call chain that costs almost nothing, however dramatic it looks. Colours are random by default and carry no meaning — do not read into them.

A CPU flame graph only shows threads that are on CPU. A thread blocked on a disk read, a lock, or a socket does not appear at all — it is not running, so it is never sampled.

This is why "the flame graph looked fine" is such a dangerous sentence. A service spending 95% of its time waiting produces a small, tidy, entirely truthful CPU profile that answers a question nobody asked. Check the CPUs utilized ratio from perf stat first: near 1.0 per thread means a CPU profile will tell you something; near 0 means the answer is off-CPU, and you need Part D.

Real-world analogy — the clipboard survey on the factory floor

A manager wants to know where a factory's time goes. They cannot follow every worker all day, so they do something cheaper: every ten seconds, they look up and write down what the nearest worker is doing, and who asked them to do it.

After an hour they have several hundred slips of paper. Sort them into piles and the tallest pile is where the time went — not because they measured it, but because the more often something is happening, the more often the ten-second glance lands on it. That is sampling, and the flame graph is those piles stacked so that each slip also shows the chain of instructions that led to the task.

The width of a pile is how often, not when. Two piles side by side say nothing about which happened first, and a manager who reads the chart left to right as a timeline will reach confident, wrong conclusions.

And here is the limitation that catches everyone: a worker standing idle, waiting for a delivery, is not "doing" anything, so nothing gets written down. A factory that is 95% blocked on deliveries produces a beautiful, sparse, completely accurate survey of the 5% that was working — and the manager concludes the floor is efficient.

Where the analogy stops working. A manager can walk over and ask the idle worker what they are waiting for. Getting the same answer from a blocked thread needs a different instrument entirely — off-CPU tracing, which is Part D.

🧪 Exercise C2.1 — Profile something, then draw it
bash
# 1. Sample a CPU-bound workload with call stacks
perf record -F 99 -g -o /tmp/perf.data \
  -- timeout 5 bash -c 'x=0; while :; do x=$((x+1)); done'

# 2. Which event did it actually use?
perf evlist -i /tmp/perf.data

# 3. Read it as text first - often enough on its own
perf report -i /tmp/perf.data --stdio --no-children | head -20

# 4. Now the flame graph
[ -d /tmp/FlameGraph ] || git clone --depth 1 https://github.com/brendangregg/FlameGraph /tmp/FlameGraph
perf script -i /tmp/perf.data > /tmp/out.perf
wc -l < /tmp/out.perf
/tmp/FlameGraph/stackcollapse-perf.pl /tmp/out.perf > /tmp/out.folded
wc -l < /tmp/out.folded
sort -k2 -nr /tmp/out.folded | head -2
/tmp/FlameGraph/flamegraph.pl --title "bash spin loop" /tmp/out.folded > /tmp/cpu.svg
ls -l /tmp/cpu.svg
# open /tmp/cpu.svg in a browser - it is interactive, click to zoom
Expected result — click to reveal
plain text
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.090 MB /tmp/perf.data (486 samples) ]

task-clock:ppp

# Samples: 486  of event 'task-clock:ppp'
# Event count (approx.): 4909090860
#
# Overhead  Command  Shared Object      Symbol
# ........  .......  .................  ...................................
#
     7.61%  bash     libc.so.6          [.] __strlen_evex
            |
            ---__strlen_evex
               |
               |--1.65%--print_simple_command
               |          0x55b14d842219
               |          execute_command_internal
               |          execute_command

9041
174
bash;_start;__libc_start_main@@GLIBC_2.34;...;execute_command_internal;[bash] 242424240
bash;_start;__libc_start_main@@GLIBC_2.34;...;execute_command_internal;[bash];[bash] 181818180

-rw-r--r-- 1 root root 52429 Aug 22 12:11 /tmp/cpu.svg

What to read out of this.

Look at line 2: the event is task-clock:ppp, not cycles. (Run as an ordinary user it reads task-clock:upppH instead — u because perf_event_paranoid = 2 restricts you to user-space events, H for host. Same fallback, extra modifiers.) You did not ask for that. perf record defaults to the hardware cycles event, found no PMU on this VM, and silently fell back to a software timer. That is good behaviour and it is worth knowing about, because your samples are now measured in wall-clock time on CPU rather than in cycles — fine for finding hot functions, useless for anything about instructions-per-cycle. Always run perf evlist on a profile before you draw conclusions from it. The :ppp suffix is the precision modifier, requesting maximum precision.

486 samples over 5 seconds at 99 Hz is what you expect from roughly one busy CPU: 99 × 5 ≈ 495. If your sample count is far below that, the process was not on CPU as much as you thought — which is a finding, not a problem with perf.

perf report is often the whole answer, and people skip straight past it to the picture. Here the top symbol is __strlen_evex in libc at 7.61% — a shell spinning on arithmetic spends its time in string handling, which is exactly right for bash and would surprise anyone who assumed the loop was doing arithmetic. --no-children shows self time, the time actually in each function; without it you get cumulative time including callees, and the top entry is always _start, which tells you nothing.

The pipeline shrinks the data enormously: 9041 lines of perf script become 174 folded lines. Each folded line is one unique stack plus a count, so it is greppable — grep mysql out.folded before drawing is a very effective way to answer "how much time is in the database driver?".

And now the flaw that Section C3 is about. Look at the folded stacks: they are full of [bash] entries. That is a frame perf could not resolve to a symbol. Each one is a real function that will appear in your flame graph as an unlabelled box, and a flame graph made mostly of [unknown] boxes is the most common disappointment in this entire subject.

Now imagine this at 500 hosts. Keep the folded file, not the SVG. It is small, it is plain text, it diffs, and it is the input to a differential flame graph (difffolded.pl) — which is how you show that release N+1 spends 12% more time in one function than release N. A screenshot of an SVG in a ticket proves nothing; two folded files from before and after a deploy prove exactly what changed.

C3 · Why your stacks are broken

Nearly everyone's first attempt is disappointing in one of two ways: perf refuses to run at all, or it runs and produces a wall of [unknown]. Those are different problems. Two causes stop perf runningperf_event_paranoid, and seccomp inside a container. Three causes break the symbols — missing frame pointers, missing debug symbols, and kptr_restrict for the kernel half. All five are checkable in one command each.

1. kernel.perf_event_paranoid — permission to measure.

ValueWhat an unprivileged process may do
-1Everything, including raw tracepoints
0Per-process and system-wide, but no raw or ftrace function tracepoints
1Per-process only — no system-wide (-a) profiling
2Per-process only, and user-space events only — no kernel profiling. This is the mainline default
3Not in mainline. Added by the CONFIG_SECURITY_PERF_EVENTS_RESTRICT patch carried by Debian and Android kernels and inherited by Ubuntu: unprivileged perf_event_open is refused outright. Some hardened kernels go further — read the value, do not assume it

The privilege that lifts these is CAP_PERFMON (Linux 5.8, from Module 13) — CAP_SYS_ADMIN still works but is the old answer. Most tutorials say sudo, which is the same thing with a bigger hammer.

2. kernel.kptr_restrict — permission to see kernel symbol names. At the default 0 kernel pointers printed to unprivileged readers are hashed, not raw; at 1 they are zeroed unless you hold CAP_SYSLOG, and at 2 always zeroed. When it is non-zero, perf cannot resolve kernel symbols from /proc/kallsyms and warns that "Samples in kernel functions may not be resolved". The symptom is a flame graph whose kernel half is hexadecimal.

3. Frame pointers — the ability to walk the stack at all. For a decade compilers omitted the frame pointer by default to free a register, which makes the stack unwalkable by the cheap method. Your options:

--call-graph fp (the default)Cheapest and deepest — but only works if everything in the stack was built with frame pointers
--call-graph dwarfWorks without them, by copying a chunk of stack per sample and unwinding it later. Heavy, and truncates at the configured stack-dump size — 8192 bytes by default, tunable as dwarf,16384
--call-graph lbrHardware Last Branch Record: cheap, shallow, and unavailable on most cloud VMs

The industry moved on this recently and it is worth knowing the details: Fedora 38 turned -fno-omit-frame-pointer on by default, and Ubuntu 24.04 LTS followed on 64-bit architectures only — Canonical measured the cost at 1–2% in most cases, and explicitly kept omitting them where the penalty is high, such as the Python interpreter. So "modern distro, therefore good stacks" is not something you can assume; check the binary you actually care about.

4. Containers. Inside a container the usual blocker is not capabilities — it is seccomp. Docker's default profile blocked perf_event_open outright from v17.06, and perf reports "No permission to enable … event" (the underlying errno is EPERM). Since Docker 23.0 (moby #43988, in 22.06) the default profile allows it again — but only for a container that holds CAP_PERFMON, so on a modern daemon docker run --cap-add PERFMON is enough and no custom profile is needed. On older daemons you still need a profile that allows the call. And kernel.perf_event_paranoid is not namespaced, so it must be set on the host. --privileged also works and is the wrong tool.

Interview-grade detail. A fifth cause exists for managed runtimes and it catches people out: JIT-compiled code has no symbols on disk at all, because the functions did not exist until the process ran. The JVM, Node and .NET solve this by writing a /tmp/perf-<pid>.map file that maps addresses to names, which perf reads automatically — but only if the process was started with the right flag (-XX:+PreserveFramePointer and an agent for the JVM, --perf-basic-prof for Node). Without it, a Java flame graph is a solid wall of hex, and no amount of sysctl changes it.
Real-world analogy — the photograph of the meeting

You want to know who was in a meeting, so you photograph the room. Four things can ruin the photo, and all four have equivalents here.

You are not allowed in the building. That is perf_event_paranoid: nothing to do with your camera, everything to do with the door.

You are allowed in, but the name badges have been blacked out. You can see who was present and count them; you cannot say who they were. That is kptr_restrict — the picture is real and the labels are gone.

Everyone is standing behind everyone else. You can see the front row and have no idea who is at the back. That is a missing frame pointer: the top of the stack is visible and the chain of callers is not. Bringing a much better camera — --call-graph dwarf — gets you the back rows at the cost of a far heavier and slower photograph.

Half the attendees joined ten minutes ago and are not on the attendee list. That is JIT-compiled code: real people, doing real work, with no name anywhere on file.

The useful part of the analogy is the order. Check the door, the badges, the sightlines and the list — in that order — before blaming the camera. Almost everybody blames the camera.

Where the analogy stops working. A photograph captures a moment; a profile is hundreds of glances merged, so a person who left before the first glance is invisible no matter how good the lighting.

🧪 Exercise C3.1 — Check the five causes, then hit one deliberately
bash
# 1. The two sysctls
sysctl kernel.perf_event_paranoid kernel.kptr_restrict

# 2. As an ordinary user: profiling your OWN process is allowed at level 2.
#    Note the ":u" suffix perf adds - user-space events only.
su "$USER" -c 'perf stat -e task-clock -- sleep 0.1'

# 3. As an ordinary user: system-wide profiling is NOT (exit 255)
su "$USER" -c 'perf stat -a -e task-clock -- sleep 0.1'

# 4. Does this binary have frame pointers? (needs `file` and `objdump`)
objdump -d "$(command -v bash)" 2>/dev/null \
  | grep -c 'push.*%rbp' || echo "objdump not installed"

# 5. In a container, the blocker is seccomp, not capabilities:
#    docker run --rm IMAGE perf stat -e task-clock -- true        -> refused
#    docker run --rm --security-opt seccomp=unconfined IMAGE ...   -> works, no extra caps
#    docker run --rm --cap-add PERFMON IMAGE ...                   -> works on Docker >= 23.0
Expected result — click to reveal (one deliberate failure)
plain text
kernel.perf_event_paranoid = 2
kernel.kptr_restrict = 0

 Performance counter stats for 'sleep 0.1':

              1.05 msec task-clock:u              #    0.010 CPUs utilized

       0.101815862 seconds time elapsed

       0.001388000 seconds user
       0.000000000 seconds sys

Error:
Access to performance monitoring and observability operations is limited.
Consider adjusting /proc/sys/kernel/perf_event_paranoid setting to open
access to performance monitoring and observability operations for processes
without CAP_PERFMON, CAP_SYS_PTRACE or CAP_SYS_ADMIN Linux capability.
More information can be found at 'Perf events and tool security' document:
https://www.kernel.org/doc/html/latest/admin-guide/perf-security.html
perf_event_paranoid setting is 2:
  -1: Allow use of (almost) all events by all users
      Ignore mlock limit after perf_event_mlock_kb without CAP_IPC_LOCK
>= 0: Disallow raw and ftrace function tracepoint access
>= 1: Disallow CPU event access
>= 2: Disallow kernel profiling
To make the adjusted perf_event_paranoid setting permanent preserve it
in /etc/sysctl.conf (e.g. kernel.perf_event_paranoid = <setting>)

What to read out of this.

Step 2 succeeds and step 3 fails, and the difference is one flag. At perf_event_paranoid = 2 an unprivileged user may profile their own process, so perf stat -- sleep 0.1 works. Add -a for system-wide and the kernel refuses, with exit status 255 — and, unusually for a Linux error, it prints the entire table of levels, and names CAP_PERFMON as the fix. That is the friendliest permission error in this whole track; read it rather than reaching for sudo.

Note the :u modifier on the event name in step 2. At level 2 an unprivileged user gets user-space events only, which is the same restriction that refuses -a. Run the identical command as root and the event is plain task-clock. If your two runs disagree, check which user produced which.

What to do about it, in order. For a one-off investigation, sudo perf … is fine. For a machine where developers profile routinely, set kernel.perf_event_paranoid = 1 (per-process for everyone, no system-wide) or 0, deliberately and fleet-wide. For a service that must profile itself, grant it CAP_PERFMON rather than root — that is precisely the capability that was carved out of CAP_SYS_ADMIN in Linux 5.8 so that observability tools would stop needing the junk drawer.

kptr_restrict = 0 is the modern default, but on its own it does not get you kernel symbols: /proc/kallsyms still hands 0000000000000000 to a process that has neither CAP_SYSLOG nor perf_event_paranoid <= 1. Verify with head -2 /proc/kallsyms as the user that will run perf — if the addresses are zeros, that is why your kernel frames are hex, and the knob to move is usually perf_event_paranoid, not kptr_restrict.

Step 4 is a heuristic, not a proof. A high count of push %rbp at function entry suggests frame pointers are present; a near-zero count on a large binary suggests they were omitted. The definitive answer is how the distribution built the package — Ubuntu 24.04 and Fedora 38 onwards mostly have them on 64-bit, except where they were deliberately left off, notably the Python interpreter.

And the third symbol-breaker is missing debug symbols: install the -dbgsym / -debuginfo package for the binary you are profiling, or you will get correct addresses with no names. For a managed runtime add a fourth — JIT-compiled frames, which need the runtime's perf-<pid>.map and therefore a restart.

Now imagine this at 500 hosts. Decide the profiling posture once, as policy, and bake it into the image: the perf_event_paranoid value, whether -dbgsym packages are installed, whether the JVM runs with -XX:+PreserveFramePointer, and a seccomp profile for the containers that need perf_event_open. Doing this in advance is a ten-minute change. Doing it during an incident means an SSH session, a sysctl, a restart of the process to add a JVM flag — and the moment you restart it, the problem you were investigating goes away.

C4 · Interview questions — profiling

Q. What is perf, and how would you use it to analyse a performance problem?

perf is the kernel's own profiler, built on the perf_events subsystem. It works in two modes and I would use them in order.

Count first: perf stat adds up events over a run at almost no cost. The ratio I look at before anything else is CPU time versus elapsed timeCPUs utilized in its output. Near 1.0 per thread means CPU-bound and worth profiling; near zero means the process is blocked and a CPU profile would be an empty, truthful, useless picture.

Then sample: perf record -F 99 -a -g -- sleep 30 takes 99 stack samples per second per CPU. perf report --stdio --no-children is often the whole answer; the flame graph is for sharing it and for spotting shapes.

Read the flame graph correctly: width is the fraction of samples, the x-axis is not time, and you read top-down for cost and bottom-up for cause.

The details that separate candidates: knowing what breaks. Hardware events like cycles are usually <not supported> on a cloud VM because there is no virtual PMU, and perf silently falls back to task-clock — so run perf evlist on the profile before drawing conclusions. perf_event_paranoid defaults to 2 and blocks -a. Missing frame pointers give [unknown] frames, fixed with --call-graph dwarf at a real cost. And inside a container the blocker is usually seccomp, not capabilities, because Docker's default profile blocks perf_event_open.

Q. Your flame graph is a wall of [unknown]. What is wrong?

Four causes, checked in this order.

Missing frame pointers — the compiler omitted them, so the stack cannot be walked cheaply. Confirm by re-recording with --call-graph dwarf; if the stacks appear, that was it. The long-term fix is building with -fno-omit-frame-pointer, which Fedora 38 and Ubuntu 24.04 now do by default on 64-bit — though Ubuntu deliberately excludes the Python interpreter, so "modern distro" is not a guarantee.

Missing debug symbols — install the -dbgsym or -debuginfo package. You get correct addresses and no names without it.

kptr_restrict non-zero, if it is specifically the kernel frames that are hex.

JIT-compiled code — the functions did not exist on disk, so there is nothing to look up. The JVM, Node and .NET can emit a /tmp/perf-<pid>.map, but only if started with the right flag, which means a restart.

The details that separate candidates: stating the trade-off of --call-graph dwarf rather than just recommending it. It copies a chunk of stack per sample — heavy enough to change the behaviour of what you are measuring, and it truncates at the configured stack size, so deep recursion still comes out wrong. lbr is cheap but shallow and unavailable on most cloud VMs. Frame pointers remain the only option that is cheap, deep and correct, which is why distributions went back to them after a decade.

Q. When is a CPU flame graph the wrong tool?

Whenever the problem is waiting rather than computing — which, for a typical web service, is most of the time.

A CPU profile samples threads that are on CPU. A thread blocked on a disk read, a database round trip, a mutex, or a socket is not running, so it is never sampled and never appears. The graph you get is small, tidy and completely honest about the 3% of time that was spent computing.

The check that prevents the wasted afternoon is one number from perf stat: CPUs utilized. Near 1.0 per thread, profile the CPU. Near zero, the answer is off-CPU.

For off-CPU work the tools are different: /proc/<pid>/wchan and /proc/<pid>/stack for a stuck process, PSI for whether the stall is CPU, memory or I/O, and off-CPU flame graphs built from scheduler tracepoints (sched:sched_switch and sched:sched_stat_sleep) with perf or bpftrace — where the y-axis is blocked time rather than CPU time.

The details that separate candidates: naming the "off-CPU flame graph" as a specific technique rather than gesturing at eBPF, and noting that the two are complementary rather than alternatives — Gregg's own advice is to produce both and read them together, because a change that moves time from one to the other looks like an improvement in whichever one you happened to look at.


🔎 Part D · Tracing

D1 · strace, and why it must never be your production tool

Official docs: strace(1) · perf-trace(1) · ltrace(1)

Profiling samples. Tracing records every occurrence — every system call, every scheduler switch, every disk request. That completeness is exactly what you want when the problem is rare, and exactly what makes it expensive.

strace is the tool everybody knows, and it is built on ptrace(2), the debugging interface. The mechanism is the whole story. As Brendan Gregg puts it, it "operates in a violent manner: pausing the target process for each syscall so that the debugger can read state. And doing this twice: when the syscall begins, and when it ends."

Two stops per system call, each a context switch into the tracer and back. For a process making a hundred thousand system calls a second, that is four hundred thousand extra context switches a second — two stops, and each stop is a switch into the tracer and back.

Never attach strace to a busy production process without knowing what you are about to do to it. You will measure a slowdown in the hundreds of times, not percent — the exercise below records 219× on a real machine. If the process is holding a lock, serving a health check, or has a watchdog, you may take the service down rather than diagnose it.

And the part that surprises people most: plain strace pays that cost even for system calls you did not ask to trace. Filtering with -e trace=openat does not stop the kernel stopping the process on every read; it only stops strace printing it. The exercise measures that too.

Interview-grade detail — the mitigation almost nobody knows. Modern strace has --seccomp-bpf, which installs a seccomp filter (Module 13) so the kernel only stops the process for the calls you actually asked for. On the "tracing a syscall the process never makes" case the difference is the entire overhead: from 12.6 seconds back to 0.075 seconds in the measurement below.

Two gotchas straight from the man page, and knowing them is what makes this a real answer rather than a fact: it has no effect unless -f / --follow-forks is also given, and it is incompatible with --syscall-limit and -b / --detach-on.

Real-world analogy — the customs officer at the factory gate

A factory has one gate, and lorries come and go all day. You want to know what is being shipped.

strace is posting a customs officer at the gate who stops every single lorry, on the way in and on the way out, opens the paperwork, writes it down, and waves it on. The record is perfect. The factory's throughput collapses, because the gate was never designed for a full stop and a conversation per lorry.

And here is the detail that catches people. You tell the officer "I only care about chemical shipments." The officer still stops every lorry — they have to open the paperwork to find out whether it is chemicals. You have reduced the paperwork, not the stopping. That is strace -e trace=openat on a process doing millions of reads.

strace --seccomp-bpf is issuing the gate a barrier that reads number plates automatically and only raises for the lorries you named. Everything else drives straight through at full speed.

And the alternative design — perf trace, ftrace, eBPF — is a camera and a logbook rather than a barrier: lorries are recorded as they pass, at speed, and you read the log later.

Where the analogy stops working. A customs officer can ask the driver a question. ptrace can do rather more than observe — it can modify registers, inject calls, and change what the process sees, which is why it is a debugger interface and why it needs the privileges it does.

🧪 Exercise D1.1 — Measure the damage yourself

This is the exercise to remember, and every number in the expected output is a real measurement.

bash
# Baseline: 200,000 one-byte reads, untraced
echo "--- untraced ---"
time ( dd if=/dev/zero of=/dev/null bs=1 count=200000 2>&1 | tail -1 )

# The same, traced. Note we ask for ONE syscall: read.
echo "--- strace -e trace=read ---"
time ( strace -f -c -e trace=read dd if=/dev/zero of=/dev/null bs=1 count=200000 2>&1 | tail -4 )

# Now trace a syscall dd essentially never makes. Should be free, surely?
echo "--- strace -e trace=openat (dd makes ~4 of these) ---"
time ( strace -f -c -e trace=openat dd if=/dev/zero of=/dev/null bs=1 count=200000 2>&1 | tail -4 )

# The same, with the kernel doing the filtering
echo "--- strace --seccomp-bpf -e trace=openat ---"
time ( strace -f --seccomp-bpf -c -e trace=openat dd if=/dev/zero of=/dev/null bs=1 count=200000 2>&1 | tail -4 )
Expected result — click to reveal
plain text
--- untraced ---
200000 bytes (200 kB, 195 KiB) copied, 0.0740487 s, 2.7 MB/s
real	0m0.076s
user	0m0.030s
sys	0m0.048s

--- strace -e trace=read ---
------ ----------- ----------- --------- --------- ----------------
100.00    1.656284           8    200001           read
------ ----------- ----------- --------- --------- ----------------
100.00    1.656284           8    200001           total
real	0m16.642s
user	0m1.375s
sys	0m14.268s

--- strace -e trace=openat (dd makes ~4 of these) ---
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         4           openat
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         4           total
real	0m12.579s
user	0m0.883s
sys	0m10.807s

--- strace --seccomp-bpf -e trace=openat ---
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         4           openat
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         4           total
real	0m0.075s
user	0m0.038s
sys	0m0.039s

What to read out of this. Four numbers: 0.076, 16.6, 12.6, 0.075 seconds.

0.076 → 16.6 seconds is a 219× slowdown. That is the headline, and it is why "just strace it" is not a production answer. On a service handling real traffic, a 200× slowdown is an outage you caused while investigating a slowdown.

Now the third number, which is the one that teaches the mechanism. Tracing openat — a call dd makes exactly four times — still cost 12.6 seconds. The filter reduced what was printed, not what was stopped. Every one of those 200,000 read calls still stopped the process twice so strace could look at it and decide it was not interesting. -e is a display filter, not a performance control, and almost nobody knows this until it bites them.

And the fourth number is the fix: 0.075 seconds — indistinguishable from untraced. --seccomp-bpf pushed the filtering into the kernel, so the process is only stopped for openat. Same output, same four calls recorded, the entire overhead gone.

Two conditions on that fix, and both are easy to miss. It does nothing without -f — drop the -f and the same command takes about 13 seconds again, which is the cheapest possible proof of the gotcha. And it is not applicable to -p / --attach at all, so it cannot rescue you on a process that is already running — which is precisely the production case.

Look at where the time went, too. In the traced runs sys is 14.3 and 10.8 seconds against roughly 1 second of user. The cost is almost entirely kernel time — context switching in and out of the tracer — which is exactly what the two-stops-per-syscall mechanism predicts.

What to use instead in production. perf trace is the buffered, ring-buffer equivalent built on perf_events. Published figures put strace anywhere from 173× (Arnaldo Carvalho de Melo, via PingCAP) to 442× (Gregg, dd bs=1 count=500k), and perf trace at about 1.36× on a dd workload — but those are separate experiments, not one head-to-head. An eBPF tracer is cheaper still. Measure it on your own hardware rather than trusting a ratio: on a small VM with a restricted PMU, perf trace can lose its advantage entirely.

And when strace is the right tool: a process that is already stuck and doing nothing, a short-lived command in development, or a one-shot strace -f -e trace=openat -o /tmp/log ./thing to answer "which config file is it actually reading?". Completeness on an idle process costs nothing.

Now imagine this at 500 hosts. Put strace behind a rule in the runbook: never on a process serving traffic, and if it must be used, take the instance out of the load balancer first. The habit worth building instead is strace -c (a summary, not a firehose) on a copy of the workload in staging, and perf trace or bpftrace when it has to be the live one. The most expensive incident in this area is always the same shape: an engineer straces a stuck-looking process, the process was not stuck but merely slow, and now it is genuinely stuck.

D2 · The four places you can attach a probe

Official docs: Event Tracing · Kprobes · Uprobe-tracer

Every modern tracing tool — ftrace, perf trace, bpftrace, bcc, and every commercial agent — is a front end over the same four kinds of hook. Knowing which one a tool is using tells you both how much it costs and how likely it is to break on the next kernel upgrade.

HookWhere it attachesStabilityCost
TracepointA named point a kernel developer put there on purpose — sched:sched_switch, block:block_rq_issue, syscalls:sys_enter_openatThe best available. Treated as an interface in practice — though the kernel has never formally promised itNothing when disabled. Implemented with static keys, so it is a nop until switched on
kprobeAny kernel function, by name or address, whether or not anyone intended it to be traceableNone. The function may be renamed, inlined or deleted in the next release, and its struct fields may change under you0.5 µs per hit unoptimised; ≈ 0.06 µs when jump-optimised — roughly 8× cheaper. (The kernel documentation's own figures, measured on a 2005 Opteron and a 2008 Xeon; modern CPUs are faster, and the ratio is the point)
uprobeAny user-space function, by symbol or offset in a binaryNone, and worse: a rebuild of the binary can move the offsetHigher than a kprobe — each hit traps into the kernel
USDTA static probe the application's author declared — in PostgreSQL, the JVM, Node, MySQLGood. A deliberate, declared boundary, the user-space equivalent of a tracepointA single nop when disabled; a few microseconds when enabled
The counter-intuitive part: a disabled tracepoint costs essentially nothing, which is why your kernel is full of them.

There are one to two thousand compiled into a typical distribution kernel, and 733 even on a trimmed cloud one — cat /sys/kernel/tracing/available_events | wc -l will count yours — and they are not slowing the machine down, because each is a static key: a nop instruction that the kernel rewrites into a jump only when someone enables it. You are not choosing between "instrumented" and "fast"; the instrumentation is already there, switched off, waiting.

The practical consequence is a rule of thumb worth stating plainly: prefer a tracepoint if one exists; fall back to a kprobe only when it does not. A tool built on sched:sched_switch still works in three years. A tool built on a kprobe on __do_page_fault breaks the day someone renames it — and it will break silently, by simply matching nothing.

Real-world analogy — the four ways to find out what a factory is doing

Tracepoints are the sensors the factory's own engineers installed. They are at the places that matter, they are labelled, they are documented, and the readouts stay in the same place when the line is refitted — because the engineers refit them too. They also cost nothing while switched off: the sensor is bolted on and the wire is not connected.

kprobes are clipping your own meter onto a wire you found, in a machine nobody documented. It works, and it tells you something no sensor does. It also has no promise attached: the next refit may move that wire, rename it, or remove it because the function it served got folded into another machine.

uprobes are the same trick on a supplier's machine. Worse, because the supplier ships a new revision whenever they like and the wire you clipped onto is at a slightly different place inside.

USDT is a socket the supplier fitted for exactly this purpose, with a plug on the front and a label. It is the same idea as a tracepoint, offered by whoever wrote the application.

The rule falls straight out: use a labelled socket if one exists, clip onto a wire when it does not, and write down which you did — because one of those choices needs re-checking every time something is upgraded and the other does not.

Where the analogy stops working. A clipped-on meter is visible; someone doing maintenance will spot it. A kprobe that has silently stopped matching anything looks exactly like a system with no problems, and that is the failure mode to be afraid of.

D3 · ftrace and bpftrace

ftrace is the kernel's built-in tracer, and it is a filesystem rather than a program. Everything is done by reading and writing files under /sys/kernel/tracing, which means it works on any machine, needs nothing installed, and cannot be blocked by a missing package.

FileWhat it is for
available_eventsEvery tracepoint compiled into this kernel, as subsystem:event
events/<sub>/<event>/enableWrite 1 to switch a tracepoint on, 0 to switch it off
available_tracers / current_tracerThe function-level tracers: function, function_graph, blk, latency tracers, and nop for none
traceThe ring buffer as a snapshot. Reading it does not consume it
trace_pipeThe same data as a live stream. Reading consumes it, and blocks waiting for more
set_ftrace_filter, set_event_pidNarrow it down before you switch anything on
Three ftrace details that cost people an afternoon.

/sys/kernel/tracing is the modern path. Before Linux 4.1 the files lived under /sys/kernel/debug/tracing, and for compatibility mounting debugfs still auto-mounts tracefs there. Every tutorial older than about 2016 uses the debugfs path, and on a machine that does not mount debugfs it simply will not exist. Use /sys/kernel/tracing and mount it yourself if it is empty: mount -t tracefs nodev /sys/kernel/tracing.

Changing current_tracer clears the ring buffer — both it and the snapshot buffer. Collect your data before you switch tracers, not after.

trace and trace_pipe are not interchangeable. trace is a snapshot you can read repeatedly. trace_pipe drains the buffer and blocks; pipe it through head and the data you did not read is gone. Enabling function tracing machine-wide and then reading trace_pipe on a busy box is also a very effective way to overwhelm your terminal.

bpftrace is the modern layer above all of this: a small language that compiles to eBPF and attaches to any of the four hook types, aggregating in the kernel so only summaries cross into user space. That in-kernel aggregation is the whole reason it is cheap enough for production, where strace is not.

bash
# Which processes are exec'ing what?
bpftrace -e 'tracepoint:sched:sched_process_exec { printf("%s -> %s\n", comm, str(args->filename)); }'

# A histogram of read() sizes, aggregated in the kernel
bpftrace -e 'tracepoint:syscalls:sys_exit_read /args->ret > 0/ { @bytes = hist(args->ret); }'

# Which files is anything opening?
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm, str(args->filename)] = count(); }'

It wants Linux 4.9 or newer and, in practice, root — the capability decomposition (CAP_BPF plus CAP_PERFMON, from Linux 5.8) exists but is not a documented supported mode of the tool. It is packaged on Ubuntu, Debian, Fedora, CentOS, Alpine, Arch and openSUSE; check bpftrace --version before copying a one-liner from the internet, because the language has changed across releases.

ToolReach for it whenCost
straceA stuck or idle process; "which config file is it reading?"Hundreds of times slower. Never on live traffic
perf traceThe same questions, on a process you cannot stopBuffered; far cheaper. Measure it yourself
ftraceNothing is installed, and you need a kernel tracepoint nowNear zero for tracepoints; heavy for function tracing
bpftraceYou need a summary — a histogram, a count by process, a latency distributionLow, because the aggregation happens in the kernel
🧪 Exercise D3.1 — Trace every program the machine starts, with nothing installed
bash
cd /sys/kernel/tracing 2>/dev/null || {
  sudo mount -t tracefs nodev /sys/kernel/tracing && cd /sys/kernel/tracing; }

# How much instrumentation is already compiled into this kernel?
wc -l < available_events
cut -d: -f1 available_events | sort -u | head -8

# Which function-level tracers does this kernel offer?
cat available_tracers

# Switch on ONE tracepoint, do something, read the buffer
sudo sh -c 'echo > trace'          # clear it first - `trace` is not a consumer
sudo sh -c 'echo 1 > events/sched/sched_process_exec/enable'
( ls / >/dev/null; date >/dev/null; sleep 0.3 )
sudo head -16 trace
sudo sh -c 'echo 0 > events/sched/sched_process_exec/enable'
Expected result — click to reveal
plain text
733
alarmtimer
amd_cpu
avc
block
bpf_test_run
bpf_trace
bridge
capability

blk nop

# tracer: nop
#
# entries-in-buffer/entries-written: 5/5   #P:2
#
#                                _-----=> irqs-off/BH-disabled
#                               / _----=> need-resched
#                              | / _---=> hardirq/softirq
#                              || / _--=> preempt-depth
#                              ||| / _-=> migrate-disable
#                              |||| /     delay
#           TASK-PID     CPU#  |||||  TIMESTAMP  FUNCTION
#              | |         |   |||||     |         |
              ls-6932    [001] ..... 18743.713170: sched_process_exec: filename=/usr/bin/ls pid=6932 old_pid=6932
            date-6933    [001] ..... 18743.715089: sched_process_exec: filename=/usr/bin/date pid=6933 old_pid=6933
           sleep-6934    [000] ..... 18743.716902: sched_process_exec: filename=/usr/bin/sleep pid=6934 old_pid=6934

What to read out of this.

733 tracepoints are already compiled into this kernel, across dozens of subsystems, and every one of them was costing nothing until the moment you wrote a 1. That is the static-key point from D2, made concrete: the instrumentation is present and switched off, not absent.

available_tracers says blk nop — and that is a real finding, not an error. This kernel was built without CONFIG_FUNCTION_TRACER, so function and function_graph are simply not on the menu. Cloud and container-host kernels are often trimmed this way. Check available_tracers before planning an investigation around function tracing, because the tutorial you are following was written on a kernel that had it.

The trace output is the payoff, and it needed nothing installed. No perf, no bpftrace, no package manager, no network. Just a write to a file and a read from another. On a locked-down production host where you cannot install anything, this is frequently the only tracing you will get — which is exactly why it is worth knowing the paths by heart.

Read the columns. ls-30486 is command and PID, [001] is the CPU, the five dots are per-event flags (interrupts off, need-resched, hardirq/softirq context, preempt depth, migrate-disable), then a timestamp in seconds since boot, then the event and its fields. sched_process_exec fires on every exec, so this is a complete record of every program the machine started while it was enabled — the same data execsnoop reports, from a file.

Clear the buffer before you enable anything, which is why the exercise writes to trace first. Reading trace does not consume it, so a second run appends to whatever the first left behind — the commonest way to read someone else's trace and believe it is yours.

Remember to switch it off. A tracepoint left enabled keeps filling a ring buffer forever. It is bounded and it will not fill your disk, but it will quietly cost a little CPU and confuse the next person who reads trace.

Now imagine this at 500 hosts. Standardise on one tracing tool and install it everywhere in the base image — almost always bpftrace, because in-kernel aggregation is what makes it safe on a live host. The value is not the tool; it is that the same one-liner works on every machine, so the useful ones can be written down in the runbook instead of reinvented under pressure. And keep the ftrace paths in that runbook as the fallback for the hosts where nothing can be installed, because there are always some.

D4 · Interview questions — tracing

Q. What is strace, and when would you use it?

It traces a process's system calls, using ptrace(2) — the debugger interface. That mechanism is the answer to both halves of the question: the kernel stops the process twice per system call, once on entry and once on return, so the tracer can read its state.

I use it for questions of the form "what is this program actually touching?" on something that is idle, stuck, or in development: which config file it opened, why it cannot find a library, what it did just before it hung. strace -f -e trace=openat -o /tmp/log ./thing answers those in seconds.

I do not attach it to a process serving live traffic. The slowdown is measured in hundreds of times, not percent.

The details that separate candidates: two. First, -e trace= is a display filter, not a performance control — the process is still stopped on every syscall so strace can decide whether to print it, so filtering does not buy you speed. Second, the fix: --seccomp-bpf installs a seccomp filter so the kernel only stops for the calls you asked for, which removes essentially all of the overhead — but it silently does nothing unless -f is also given. Finishing with the production alternatives (perf trace for the buffered version, bpftrace when you want a summary rather than a firehose) shows you have actually had to make this choice.

Q. Compare strace, perf and eBPF. When do you reach for each?

They differ in what they observe, how much they cost, and what they give you back.

strace observes system calls only, by stopping the process, and gives you a complete transcript. Complete, expensive, development-only.

perf does two different jobs. perf stat counts events with negligible overhead; perf record samples stacks at a chosen frequency and gives you a statistical picture — cheap because it is a sample, incomplete for the same reason. perf trace is its buffered strace equivalent.

eBPF (bpftrace, bcc) attaches to any of the four hook types and — the key property — aggregates in the kernel, so a histogram of a million events crosses into user space as one histogram, not a million lines. That is what makes it viable on a live production host.

The order I actually use them: perf stat to characterise, perf record if CPU-bound, bpftrace if I need a distribution or an off-CPU answer, strace only on something already broken and idle.

The details that separate candidates: saying that sampling and tracing answer different questions, not the same question at different prices. A profile tells you where time goes on average; it will never show you the one request in ten thousand that took four seconds. Tracing will, at a cost. And adding that eBPF's real constraint is neither speed nor safety but kernel version and probe stability — a tool built on tracepoints survives upgrades, one built on kprobes may silently stop matching anything.

Q. A process is stuck. Nothing in the logs. What do you do?

Establish first whether it is spinning or blocked, because everything after that diverges. top or pidstat 1 for the process: near 100% CPU means spinning, near 0% means blocked, and the tools for the two share nothing.

If it is blocked, the state and the wait channel name the cause without any tool being installed: ps -o state,wchan:32 -p PID, and cat /proc/PID/stack for the kernel stack. D state points at disk or a network filesystem; S with a wait channel in a futex points at a lock. cat /proc/PID/status shows the signal masks (Module 04) and ls -l /proc/PID/fd shows what it has open — often the answer on its own, when the last fd is a socket to something that has gone away.

If it is spinning, profile it: perf record -F 99 -g -p PID -- sleep 10 and look at the top self-time symbol.

strace -p PID is safe here precisely because the process is doing nothing — there are no syscalls to slow down. If it is stuck in one call, strace will show it sitting there, which is a definitive answer.

The details that separate candidates: checking /proc/PID/stack at all — most people never look — and knowing to check whether the process is in the same cgroup as something being throttled (Module 12's nr_throttled), because a container at its CPU quota looks exactly like a slow process from the inside. Also mentioning SIGQUIT for a JVM, which dumps every thread stack to stdout and is often faster than any of the above.


🏁 Part E · Practice, docs and self-check

E1 · Production practice

Symptom in productionWhat is really happeningWhat to runThe fix
High load average, CPU idleLoad counts D-state tasks; something is blockedps -eo state,pid,comm \| awk '$1~/^D/'; /proc/PID/wchanFollow the block — disk, NFS, writeback. Not a CPU problem
"Disk is fine, %iowait is 0"%iowait is a subset of idle, and near zero on virtualised storageiostat -xz 1r_await/w_await, aqu-sz; /proc/pressure/ioJudge by latency and queue depth, never by %iowait
NVMe at "100% util" and nobody complaining%util counts time with ≥1 request in flight, not loadCompare r/s+w/s and aqu-sz with the device's ratingRemove %util from the dashboard for parallel devices
Memory alert fires on a healthy boxThe alert reads free, not availablefree -m; /proc/meminfo MemAvailableAlert on available, and on PSI memory pressure
Container slow, CPU well under the limitCFS throttling — quota spent early in each periodcpu.statnr_throttled, throttled_usecFewer worker threads, or a higher quota (Module 12)
Everything is slow, no host resource is busy%steal — the hypervisor is taking the CPUmpstat -P ALL 1; vmstat stMove instance, change family, or fix the credit balance. Nothing local helps
perf stat shows <not supported> for cyclesNo virtual PMU on this hypervisorperf stat -e cycles -- true — look for <not supported>Use software events. Do not chase it
Flame graph is a wall of [unknown]Missing frame pointers, symbols, or JIT mapRe-record with --call-graph dwarf to confirm-dbgsym packages; -XX:+PreserveFramePointer; frame-pointer builds
perf record -a refuses for a developerperf_event_paranoid is 2 (or 3 on Debian/Ubuntu kernels)sysctl kernel.perf_event_paranoidSet it deliberately fleet-wide, or grant CAP_PERFMON
perf fails inside a container with EPERMDocker's seccomp profile blocks perf_event_openRe-run with --security-opt seccomp=unconfined; if it works, seccomp was the blocker--cap-add PERFMON on Docker ≥ 23.0; a custom seccomp profile on older daemons. Not --privileged
Someone straced a live process and made it worseptrace stops the process twice per syscallNothing — remove straceperf trace or bpftrace. --seccomp-bpf helps only when strace starts the process, never with -p
Fleet p99 looks wrong or impossiblePercentiles are being averaged across hostsRead how the metric is computedEmit histograms and aggregate those. Percentiles do not add
Latency SLO green while users complainThe dashboard shows the mean, or mixes failed requests inCompare mean with p50, p99, p99.9Report percentiles, split success from failure
"We're at 80% CPU, plenty of headroom"Latency follows 1/(1−u); 80→90% doubles itPlot latency against utilization from real dataSet capacity targets from the knee, not from the gauge

E2 · Capstone — four performance tickets

Four tickets. For each: what you run, in what order, what you expect, and what the answer is. Attempt them before opening the toggles.

Ticket 1. Monitoring pages for load average above 40 on a 16-core host. You log in and top shows the CPUs 92% idle. Memory is fine. The application team says everything is slow.

Ticket 2. After a routine deploy, the service's p99 latency went from 120 ms to 900 ms. The p50 has not moved. Error rate is unchanged. CPU, memory and disk on every host look exactly as they did last week.

Ticket 3. A developer asks for help: their profiling session on a production-like VM produces a flame graph that is almost entirely [unknown], and perf record -a refuses to run at all with a wall of text about a "paranoid setting".

Ticket 4. An engineer attaches strace to a busy API process to find out why one endpoint is slow. Within seconds the service starts failing health checks and is removed from the load balancer. What happened, and what should they have done?

Ticket 1 — worked answer

Load average on Linux is not a CPU metric. It counts runnable plus uninterruptible-sleep tasks, so 92% idle CPU and a load of 40 are perfectly consistent: forty processes are blocked, not computing.

Find them first, in one command:

bash
ps -eo state,pid,ppid,wchan:32,comm | awk '$1 ~ /^D/'

Then, for a few of them, cat /proc/<pid>/stack and cat /proc/<pid>/wchan — the kernel function they are stuck in usually names the subsystem outright.

Confirm the resource. vmstat 1 — the b column should be non-zero and roughly match your D count. iostat -xz 1 — look at r_await and w_await for per-device latency and aqu-sz for queue depth; do not judge by %util, and do not be reassured by a low %iowait, which is a subset of idle and often zero on virtualised storage. If PSI exists, cat /proc/pressure/io settles it in one line.

The usual causes, in the order they occur: a degraded or failing disk (check dmesg for I/O errors and smartctl), an NFS or network filesystem whose server has gone away (the classic — D state, load climbing, disk perfectly idle, because the wait is on the network), or a writeback storm from something that just wrote a great deal.

Two things not to do. Do not add CPU; there is no CPU shortage. And do not report "load 40, must be CPU" — being able to explain why those two numbers coexist is the entire point of the ticket.

Ticket 2 — worked answer

p50 flat with p99 up by 7.5× is a shape, and the shape is the diagnosis: an occasional discrete event, not a general slowdown. If the whole system were loaded, p50 would have moved too.

Scope it before touching a tool. Slice the p99 by endpoint, by host, by availability zone and by customer. Nine times in ten this ends the ticket: one endpoint means a code path or a query plan; one host means that host; one zone means a network or dependency issue. Also check the request rate — a p99 over a handful of requests is noise.

Then look for the discrete event. The candidates that produce exactly this signature are a garbage-collection pause (correlate with GC logs), a lock or connection-pool exhaustion where most requests get a connection instantly and a few wait, a cold cache path going to disk or to a dependency, and retries after a timeout — where the p99 is one request's timeout plus the retry.

Apply Little's Law to the pool theory, because it is cheap: in-flight = throughput × latency. If that number now exceeds the connection or thread pool size, requests are queueing before your instrumentation starts, which produces precisely a flat p50 and an exploding tail.

And correlate with the deploy, which is why the ticket says "after a routine deploy". Overlay the p99 on the deploy marker; then diff the release for a new dependency call, a changed timeout, a changed pool size, or a query that lost an index.

The host metrics looking normal is a result, not a dead end. It rules out USE and moves you to the request path — which is where this one lives.

Ticket 3 — worked answer

Two separate problems, and the error message solves the second one for you.

The refusal is kernel.perf_event_paranoid. Read the error — it prints the whole table. At the default of 2 an unprivileged user may profile their own process but not the system (-a), and not the kernel. On a Debian- or Ubuntu-derived kernel the default may be 3, which forbids unprivileged use entirely. Options, best first: grant the profiling service CAP_PERFMON (carved out of CAP_SYS_ADMIN in Linux 5.8 for exactly this); or set kernel.perf_event_paranoid = 1 deliberately and fleet-wide on developer-facing hosts; or sudo perf for a one-off.

The [unknown] frames are a different problem and would still be there with root. Work through three causes in order, then a fourth for managed runtimes. Frame pointers: re-record with --call-graph dwarf — if the stacks appear, that was it, and the real fix is building with -fno-omit-frame-pointer. Fedora 38 and Ubuntu 24.04 do this by default on 64-bit, but Ubuntu deliberately excludes the Python interpreter, so check the binary rather than the distro. Debug symbols: install the -dbgsym / -debuginfo package. kptr_restrict, if it is specifically the kernel frames. JIT code, for a JVM, Node or .NET process — no symbols exist on disk, and the runtime must be started with the flag that writes /tmp/perf-<pid>.map.

And check perf evlist on the profile. If the machine has no PMU, perf fell back from cycles to task-clock without saying so. The profile is still valid for finding hot functions; it is not valid for anything about cycles or instructions.

Do all of this as image policy, not as an incident response — the JVM flag in particular needs a restart, which destroys the state you were investigating.

Ticket 4 — worked answer

strace uses ptrace(2), which stops the process twice per system call — once on entry, once on return. On a busy API process making tens of thousands of calls a second, that is tens of thousands of extra context switches a second. The measured slowdown on an ordinary workload is in the hundreds of times; health checks then time out and the load balancer does exactly what it is configured to do.

And the filter did not save them. -e trace=<something> is a display filter. The kernel still stops the process on every single system call so strace can look at it and decide not to print it. In a real measurement, tracing a call the process made four times still cost a 165× slowdown.

What they should have done, in order. Reproduce it in staging, where strace is free. On the live host, use a buffered tracer instead — perf trace, or bpftrace, which aggregates in the kernel so a million events come back as one histogram. If it truly must be strace, take the instance out of the load balancer first — and note that --seccomp-bpf cannot rescue you here. The man page says it "is not applicable to processes attached using -p/--attach", so the kernel-side filter exists only when strace launches the process itself.

The wider lesson worth stating: an observability tool that changes the thing it observes is not measuring your system, it is measuring a different one. Before attaching anything to production, know its mechanism — sampling, buffered tracing, in-kernel aggregation, or stop-the-process — because the mechanism, not the tool's reputation, is what determines whether it is safe.

E3 · Documentation reference

TopicWhere to read itWhy this one
The USE methodThe USE Method · Linux checklistThe definitions in the author's own words, and a tool per cell
The sixty-second checklistLinux Performance Analysis in 60,000 Milliseconds (PDF)The original ten commands. Written in 2015 — tool flags have drifted since
Golden Signals and REDGoogle SRE book, ch. 6 · Tom Wilkie, GrafanaCon EU 2018The primary sources, including the "separate successful from failed latency" rule
perfperf(1) · perf-stat(1) · perf-record(1) · perf-top(1) · perf-trace(1)perf-record(1) is where --call-graph fp / dwarf / lbr is defined
perf permissionsperf security · sysctl/kernelThe paranoid-level scopes, and the statement that the default is 2
Flame graphsFlameGraph repository · backgroundThe README carries the three-command pipeline; the page is the concept
Frame pointersUbuntu 24.04 · Fedora 38Measured costs, and Ubuntu's explicit exclusions
ftraceftrace · Event Tracing · trace-cmd(1)The tracefs-versus-debugfs history, and trace versus trace_pipe
Probe typesKprobes · Uprobe-tracerThe kprobes page has the per-hit microsecond costs
strace and its overheadstrace(1) · strace Wow Much Syscall--seccomp-bpf and its two gotchas; the mechanism explained
bpftracebpftrace(8) · repositoryman7.org does not host bpftrace; use these
PSIPressure Stall Informationsome versus full, and the avg10/60/300 windows
The sar familysar(1) · mpstat(1) · pidstat(1) · vmstat(8)Column definitions, including %steal in mpstat(1)
Little's LawLittle (1961), Operations Research 9(3) · Sigman, Columbia (PDF)The proof, and why it needs no distributional assumptions

E4 · Self-assessment

Answer these out loud before moving to Module 15. The section to reread is named after each.

  1. Name three anti-methods, and say what a method gives you that they do not. (A1)
  2. What are the three USE questions, and which of them predicts an outage? (A2)
  3. Which tool answers saturation for CPU, memory, disk and network? (A2)
  4. What kinds of problem does USE not find, and what do you do when it comes back clean? (A2)
  5. Name the Four Golden Signals and their source. Which one does RED leave out? (A3)
  6. Why can the mean latency be higher than the p99? (A3)
  7. Why can you not average per-host p99s to get a fleet p99? (A3)
  8. State Little's Law and use it to size a connection pool. (A4)
  9. Why is 80% utilization not "20% headroom"? (A4)
  10. List the ten commands of the sixty-second checklist and say why the order is what it is. (B1)
  11. Why must you discard the first line of vmstat and iostat — and why does sar not need it? (B1, B2)
  12. Load average is 40, CPU is 92% idle. Explain, and say what you run next. (B2)
  13. Why does a low %iowait not rule out a disk problem — for two separate reasons? (B2)
  14. What is %steal, and why is it the one number you cannot act on locally? (B2, B3)
  15. What does <not supported> mean in perf stat, and how does it differ from <not counted>? (C1)
  16. Which single ratio tells you whether a CPU profile will be useful? (C1)
  17. What does the x-axis of a flame graph mean? (C2)
  18. Why does -F 99 use 99 rather than 100? (C2)
  19. Name the two reasons perf refuses to run, and the three reasons its stacks come out as [unknown]. (C3, C4)
  20. What are the perf_event_paranoid levels, and which capability lifts them? (C3)
  21. Name the four probe types, and say which you should prefer and why. (D2)
  22. Why does a disabled tracepoint cost nothing? (D2)
  23. What is the difference between trace and trace_pipe? (D3)
  24. Why is strace -e trace=openat not cheap, and what makes it cheap? (D1)
  25. Why is eBPF safe on a production host when strace is not? (D3, D4)

E5 · Sources

Everything in this module was checked against these, and the exercise transcripts are drawn from real runs on a Linux 6.x virtual machine — regenerate them on your own box, because several are machine-, kernel- and version-specific. Where a claim is unusual — the mean exceeding the p99, %iowait staying at zero during a 1.2 GB write, perf silently falling back from cycles to task-clock, strace -e costing 165× to trace four system calls — the source or the measurement below is the one to cite.

Manual pages

· perf(1) · perf-stat(1) · perf-record(1) · perf-top(1) · perf-trace(1) · perf_event_open(2)

· strace(1) · ltrace(1) · trace-cmd(1) · trace-cmd-record(1) · time(1)

· vmstat(8) · mpstat(1) · pidstat(1) · sar(1) · ss(8) · proc(5)

· bpftrace is not on man7.org: bpftrace(8) on Debian manpages

Kernel documentation

· ftrace · Event Tracing · Kprobes · Uprobe-tracer

· PSI · perf security · sysctl/kernel · CPUFreq

Methodology

· The USE Method and its Linux checklist · Linux Performance Analysis in 60,000 Milliseconds · Flame Graphs and the FlameGraph repository · strace Wow Much Syscall

· Monitoring Distributed Systems — Google SRE book · Tom Wilkie, GrafanaCon EU 2018 (PDF) · Little (1961) · Sigman's Little's Law notes (PDF)

Compiler and distribution decisions

· Ubuntu 24.04 frame pointers by default · Fedora 38 -fno-omit-frame-pointer

Things to verify on your own machines rather than trust here

· kernel.perf_event_paranoid (mainline default 2; Debian and Android kernels patch in a level 3), whether /proc/pressure exists, whether available_tracers includes function, whether your hypervisor exposes a PMU, and whether the binary you care about was built with frame pointers. Every one of them differs by platform, and every one is a single command.

Next: Module 15 — Full Interview Simulation. Fourteen modules of mechanism, and one question left: can you produce it under pressure, out loud, in the order an interviewer expects? Module 15 is a run of complete interviews — a screening round, a systems deep-dive, and a debugging session where you are given a broken machine and a stopwatch — with the answers scored the way an interviewer actually scores them.
Spotted a mistake or want something added? Send me a note.