KnowSys

Performance Engineering

Follow one slow `/report` request from the complaint to the fix: how to find out where its time goes, whether it is running on the CPU at all, how a profiler and a flame graph point at the code responsible, and how to time a fix without fooling yourself.

⏱ 47 min read◆ BeginnerAssumes: a terminal and Python; processes and threads, basic C and reading a latency percentile help
Start reading

Someone tells you the /report page takes too long to load. You open the code behind it and a few places look suspicious: a database query, some string formatting, a loop that matches each order to its customer. You pick the one that looks worst and spend the afternoon making it faster. The page is exactly as slow as before.

That happens to nearly everyone, because people are bad at guessing where a program spends its time. A change made on a guess either does nothing or moves the cost somewhere harder to see. A good mechanic doesn't start replacing parts when an engine runs badly. They plug in a diagnostic reader and measure first, so the reader tells them where to look before anything gets touched.

Software has diagnostic readers too, and this chapter follows the slow /report request through them. The question we ask the whole way is: where does the time go, and how will we know our fix worked? We start by putting a profiler on the report's code, then widen out to what a real service adds: whether the request is even running code, which machine to look at, which lines burn the CPU, and how to time a change without being fooled by the measurement itself.

01Finding where the report spends its time

1.1Profiling the report's code

Let's make the report concrete. It matches 6,000 orders to 3,000 customers, so that each order can be printed with its customer's name. The first version, slow_report, does what most of us would write first: for each order, walk down the customer list until the right customer turns up. A second version, fast_report, builds a dictionary from customer id to name once, and then looks each order up in it.

Python ships with a profiler called cProfile. A profiler is a tool that watches a program run and records how much time each function took. We'll profile slow_report alone and print the two functions with the most self time, meaning time spent in a function's own lines and not in the functions it calls. After that the script times both versions with time.perf_counter(), a stopwatch, to see how much faster the second one is. Save it as prof.py and run it with python3 prof.py.

Profile a report that matches orders to customers, then time a version with a lookup table
python
Python
import cProfile, pstats, time
 
def slow_report(orders, customers):
    result = []
    for o in orders:
        for c in customers:                     # scans the whole customer list for every order
            if c["id"] == o["customer_id"]:
                result.append((o["id"], c["name"]))
                break
    return result
 
def fast_report(orders, customers):
    by_id = {c["id"]: c["name"] for c in customers}      # build a lookup once
    return [(o["id"], by_id[o["customer_id"]]) for o in orders]
 
orders    = [{"id": i, "customer_id": i % 3000} for i in range(6000)]
customers = [{"id": i, "name": f"c{i}"} for i in range(3000)]
 
pr = cProfile.Profile(); pr.enable(); slow_report(orders, customers); pr.disable()
stats = pstats.Stats(pr)
total = sum(tt for (_, _, tt, _, _) in stats.stats.values())
print("where the time went (self time, from the profiler):")
for (file, line, name), (cc, nc, tt, ct, callers) in sorted(stats.stats.items(), key=lambda kv: -kv[1][2])[:2]:
    print(f"  {name:<32} {tt:6.3f} s  {tt / total:6.1%}")
 
t = time.perf_counter(); slow_report(orders, customers); slow = time.perf_counter() - t
t = time.perf_counter(); fast_report(orders, customers); fast = time.perf_counter() - t
print(f"\nslow_report: {slow:.3f} s     fast_report: {fast:.4f} s     ({slow / fast:,.0f}x faster)")
output
C++
where the time went (self time, from the profiler):
  slow_report                       0.164 s   99.8%
  <method 'append' of 'list' objects>  0.000 s    0.2%
 
slow_report: 0.164 s     fast_report: 0.0004 s     (449x faster)

Read the first two output lines. The profiler put 99.8% of the time inside slow_report itself, and almost none in append, the list method that stores each result. The report spends its time in its own loop and nowhere else. The last line shows what fixing that one loop is worth: in the run above the dictionary version was about 449 times faster, and another run gave 463. The exact ratio changes from run to run and from machine to machine, but it stays in the hundreds.

1.2What the measurement saved us from

Nothing in that output pointed at append, at the string formatting, or at the amount of data. The time was in the loop that scans customers for every order, and a guess might have started anywhere else. Counting shows why the loop costs so much. The orders' customer ids cycle through 0 to 2,999, so the scan walks about 1,500 customers on average before it finds a match. Across 6,000 orders that makes about 9 million comparisons, each one executed by the Python interpreter. The dictionary version does 3,000 insertions and 6,000 lookups, about a thousand times fewer steps.

The pattern for everything that follows is already visible here: measure first, let the numbers choose the target, change one thing, and measure again.

This was a friendly case, though. The slow code was one Python function that kept a core busy the whole time, and we already knew which function to profile. A real /report request passes through a web server, a database and a network, on one of many machines, and a profiler attached to one program sees only a slice of that. Before reaching for more tools we need to say precisely what we're trying to improve.

02Deciding what faster means

2.1Three different goals

In the experiment, better meant that the report finished sooner. For a real service, "make /report faster" can mean three different things, and they pull in different directions.

The first is latency, the time one request takes from the moment it arrives to the moment its answer leaves. We describe latency with percentiles. The p50 is the time that half of all requests beat, so it describes the typical request. The p99 is the time that 99 of every 100 requests beat, so it describes the slowest 1%. Users feel the slow end, which is why teams watch p99. The second goal is throughput, how many requests the service finishes per second. The third is efficiency, how much hardware each request costs.

GoalThe questionTypical unitWhat usually improves it
LatencyHow long does one request take?ms at p50, p99Less work per request, less waiting
ThroughputHow many requests per second at an acceptable latency?req/s at a latency targetMore work in parallel, less waiting for shared locks
EfficiencyHow much hardware does each request cost?CPU-seconds or dollars per million requestsLess work per request

Batching shows the pull between them. Suppose /report saved up ten requests and answered them with one database query. Throughput and efficiency go up, because the fixed cost of a query is paid once for ten requests. Latency goes up too, because the first request of each batch now sits waiting for the other nine to arrive.

?Why write the goal down?

Because it decides what "better" means when you measure. A change that halves CPU per request but adds a millisecond of queueing is a win for the cloud bill and a loss for the p99. If you haven't decided which one you're after, you'll report whichever number moved the right way.

2.2How much a fix can be worth

Say the goal is /report's latency, and a profile has shown that one function owns 10% of each request. How much faster does the request get if we make that function twice as fast? The arithmetic is called Amdahl's law. If a part of the work takes a fraction p of the total and we make it s times faster, the whole thing speeds up by:

C++
speedup = 1 / ((1 − p) + p / s)
Predict before you read on

A function takes 10% of a request's time. You rewrite it to be twice as fast. How much faster is the request?

Now try a bigger slice. A function that owns 40% of the request, made twice as fast, gives 1 / (0.6 + 0.4 / 2), which is 1 / 0.8, a 25% speedup. It's the same fix as before with five times the payoff, purely because the slice it works on is four times bigger.

Amdahl's law talks about the share of a request's time that a function owns. But a share of what? A request's time isn't all spent running code, and that changes which tool can see it.

03Running or waiting

3.1Two kinds of time

Picture one /report request from the moment it arrives. A thread in the web server picks it up, and that thread can only run instructions while it sits on a core, one of the CPU's independent processors. A machine usually has more threads than cores, so they take turns. The kernel, the part of the operating system that manages the hardware, decides whose turn it is, and the piece of the kernel that does this is called the scheduler. Threads that are ready to run but have no free core wait in a line called the run queue.

Now hold two stopwatches over this request. The first is the wall clock, what a stopwatch in your hand would show from arrival to answer. The second is CPU time, which only ticks while the request's thread is running on a core. We'll call the time it spends running on a core on-CPU, and all the rest off-CPU. The scene follows the thread through one request.

One /report request: 200 ms on the wall clock, 5 ms on a core
On a corerunningBlockede.g. databaseRun queueready, no coreThe two clocks/report threadparsingwall clocktickingCPU timeticking
Step 1. A /report request arrives and a thread starts parsing it on a core. Both clocks tick: the wall clock always does, and CPU time ticks while the thread runs.
1 / 6

Off-CPU covers every reason a thread isn't running: blocked on a lock, a disk read, a network reply or a free slot in a pool of connections (a pool is a fixed set of reusable workers or connections that requests have to wait for), or ready and waiting in the run queue. This request spent 5 ms on-CPU and 195 ms off.

A profiler that looks at the code running on the cores sees none of those 195 ms. A blocked thread executes no instructions, so it leaves nothing to look at, and section 5 shows exactly how that kind of profiler takes its samples.

Predict before you read on

The request above took 200 ms, with 5 ms on a core. You find the code that ran on the core and make it twice as fast. Roughly how long does the request take now?

3.2Telling which kind you have

Compare CPU time to wall time for the slow requests. If a request takes 200 ms and its thread was on-CPU for 180 ms, the time is in the code, so profile the CPU. If it was on-CPU for 5 ms, the other 195 ms were spent waiting, and a CPU profile will give you a tidy picture of the 5 ms that don't matter. For a whole command, the shell's time prints both numbers: real is the wall clock, and user plus sys is CPU time.

Chapter 16 shows the extreme case. In a lock convoy, threads line up behind one lock and each spends most of its life waiting for it. There, p99 got eleven times worse while the CPU profile stayed flat, because waiting for a lock is off-CPU time.

Knowing which kind of time we're hunting helps once we know where to look. In production we don't yet: the slow /report could be one endpoint among fifty, on one of hundreds of machines, waiting on any of a dozen resources. We need a routine way to narrow that down.

04Narrowing down to a service and a resource

When a system is slow and you don't yet know why, a checklist beats intuition. It makes you look at the things you'd otherwise skip, and it's fast. Two checklists cover most of the ground, one for services and one for machines.

4.1The RED method: which endpoint is hurting

The complaint was about one page, but the service behind it has many endpoints. Tom Wilkie's RED method asks three things of every endpoint: Rate ("the number of requests per second"), Errors ("the number of those requests that are failing") and Duration ("the amount of time those requests take"). Duration is the latency from section 2, shown as percentiles.

Plot those three for /report against deploys and traffic changes, and you learn whether /report got slower, since when, and whether anything else changed at that moment. RED tells you which endpoint is hurting its users. It doesn't say why, and for that we look at the machines and pools behind it.

4.2The USE method: which resource is the bottleneck

Brendan Gregg's USE method is one sentence: "For every resource, check utilization, saturation, and errors." A resource is anything with a finite capacity: CPUs, memory, disks, network interfaces, and software resources such as a mutex (a lock that lets one thread at a time through), a thread pool or a connection pool.

MetricGregg's definitionWhat it looks like
Utilisation"the average time that the resource was busy servicing work"CPU 70%, disk 90% busy
Saturation"the degree to which the resource has extra work which it can't service, often queued"Run-queue length, I/O queue depth, threads waiting on a pool
Errors"the count of error events"NIC drops, ECC errors, EMFILE on accept()

The examples in the last cell are all small failures worth a look: a NIC (network interface card) dropping packets, ECC (error-correcting memory) reporting a repaired bit flip, and EMFILE, the error a program gets when it has hit its limit on open files.

?Why is saturation the one to watch?

Because utilisation is an average, and averages hide bursts. A disk that's 60% busy over a minute may have been 100% busy for twenty seconds with a queue behind it. Saturation measures that queue directly, and queueing is where latency comes from (the arithmetic is in chapter 16).

Gregg keeps a Linux checklist that maps each cell to a command. Tools such as vmstat 1 and iostat -xz 1 print a fresh line of numbers every second when you give them an interval of 1, so you watch the numbers move. The rows you'll use most:

ResourceUtilisationSaturationErrors
CPUvmstat 1: us + sy + stvmstat 1: r above the CPU count; /proc/pressure/cpuRarely visible without vendor counters
Memoryfree -mvmstat si/so (swapping); OOM kills in dmesgdmesg for hardware errors
Networksar -n DEV 1 against link speedDrops, overruns, retransmits (nstat)ip -s link errors
Diskiostat -xz 1 %utiliostat queue size above 1, high awaitsmartctl, dmesg
File descriptorsls /proc/PID/fd | wc -l against the limitn/aEMFILE from open() and accept()

A few of the terms: in vmstat, us is the share of CPU time spent in programs' own code, sy the share spent in the kernel on their behalf, and st ("steal") the share a virtual machine wanted but the host gave to another machine. r is the number of threads in the run queue, si/so are memory pages swapped in from disk and out to it, an OOM kill is the kernel ending a process because memory ran out, and a retransmit is a network packet that had to be sent again. /proc/pressure/* is PSI (pressure stall information, added in Linux 4.20). It reports the share of time some task was stalled waiting on CPU, memory or I/O, which is saturation measured directly.

4.3A USE pass, step by step

Here is the order in which to run the pass on one host. Each step either finds a saturated resource or rules one out in under a minute.

A USE pass on a slow Linux host
●
!
Errors
cheapest to check
◉
CPU
vmstat · PSI
▦
Memory
free · si/so
▤
Disk
iostat -xz
⇅
Network
nstat · sar
≡
Software
pools · locks
Step 1. Start with errors: dmesg -T | tail, NIC drops, OOM kills. An error is usually unambiguous, and checking takes seconds.
1 / 6

Gregg reckons the method "solves about 80% of server issues with 5% of the effort". Most of the rest need a profiler, the subject of section 5. One saturation is easy to miss with these tools, though, and it's the one containers add.

4.4The saturation nobody graphs: CPU throttling in containers

Containers, and Kubernetes pods in particular, are limited by a kernel feature called cgroups, which caps how much CPU, memory and I/O a group of processes may use. The CPU cap is a quota: an amount of CPU time the group may use in each 100 ms period. A container with a limit of 4 CPUs gets 400 ms of CPU time per 100 ms period, however many cores the host has. Suppose eight of its threads run flat out on a host with at least eight cores. Together they use up the 400 ms in 50 ms of wall time, and the kernel throttles the group, meaning it pauses every thread in it until the next period starts. For the other 50 ms every request in that container waits.

Three files in the cgroup directory show this. cpu.max holds the quota and the period in microseconds, cpu.stat holds usage and throttling counters, and cpu.pressure is the PSI view of the same container. The commands below read all three.

Read a container's CPU quota and throttling counters
shell
Shell
cat /sys/fs/cgroup/cpu.max
cat /sys/fs/cgroup/cpu.stat
cat /sys/fs/cgroup/cpu.pressure
output
Output
400000 100000
usage_usec 2975847350
user_usec 2014181826
system_usec 961665523
nr_periods 11985
nr_throttled 4964
throttled_usec 950837608
some avg10=20.26 avg60=16.05 avg300=17.44 total=243046491
full avg10=9.07 avg60=6.32 avg300=7.49 total=129115661

The first line says 400,000 µs of CPU per 100,000 µs period, the 4-CPU limit. In cpu.stat, nr_periods counts the periods so far and nr_throttled counts those in which the container used its whole quota and was paused until the next one. Here that's 4,964 of 11,985 periods (41%), and throttled_usec says about 951 seconds were lost to it in total. The host has ten cores, so top inside the container never showed the CPUs as full. In the PSI lines, some is the share of time at least one task in the container was stalled waiting for CPU, and full is the share when every task was. avg10=20.26 means that over the last 10 seconds, some task was stalled about 20% of the time. The 60- and 300-second averages are close to it, so this has been going on for minutes and isn't a single spike.

?Why doesn't CPU utilisation show it?

Because the usual dashboards compare usage to the node's cores, or average it over a minute. A service can average 50% of its limit and still be throttled in most periods, if its work arrives in bursts that use the whole quota in the first part of each 100 ms period. Each throttle adds up to the rest of that period to every request caught in it.

Linux 5.4 fixed one kernel cause of excess throttling (de53fd7aedb1): per-CPU slices of quota were expiring unused, so "highly-threaded, non-cpu-bound applications" were throttled "while simultaneously not consuming the allocated amount of quota". The fix helps, but bursty work under a tight limit still throttles.

4.5Three questions, three tools

RED and USE look at different things. Wilkie summed up the pair like this: "The RED Method is about caring about your users and how happy they are, and the USE Method is about caring about your machines and how happy they are." Google's SRE book adds a third list, the four golden signals, which is RED with saturation attached.

ChecklistUnit of analysisAnswersMisses
REDA service or endpointWhich endpoint is slow or failing, and since when?Why it's slow
USEA resource: CPU, disk, pool, lockWhich resource is the bottleneck?Whether users are hurting
Four golden signals (Google SRE)A serviceLatency, traffic, errors, and saturationWhich code is responsible

So the order of work is RED to find which service and endpoint regressed and when, USE on the hosts and pools behind it to find which resource it's waiting on, and a profiler to find which code is burning that resource. Suppose the pass ends with the CPU saturated. We know the resource, and now we need the code, which means sampling what the CPU is doing.

05How a sampling profiler works

5.1Tracing and sampling

In section 1, cProfile found the slow loop by recording every function call and return. That makes it a tracing profiler. A tracing profiler's call counts are exact, but the recording adds work to every call, so the program runs slower under it, and functions that make many small calls look more expensive than they are. That was fine for a script that runs for a fraction of a second. On a production service the extra cost lands on every request, so people rarely leave a tracing profiler running there.

A sampling profiler takes a different route. It pauses the program briefly at a fixed rate and writes down where the program was. At 99 samples a second on one core, it wakes 99 times a second, records a stack each time (we'll define that in a moment), and otherwise leaves the program alone. Functions that run often show up in many samples and functions that run rarely show up in few, so the counts converge on the share of CPU time each function owns. On Linux the standard tool for this is perf, and knowing how it collects one sample explains most of the ways its output can mislead you.

?Why 99 Hz and not 100?

Gregg's perf examples explain it: "to avoid accidentally sampling in lockstep with some periodic activity, which would produce skewed results." Plenty of software runs on 10 ms timers, maybe including yours. Sampling at exactly 100 Hz could land on the same phase of that timer every time.

5.2One sample, from interrupt to file

To keep the example small we'll use a toy C program standing in for the /report handler. handle_request calls three phases in turn, parse, lookup and format. Each one is marked noinline, which tells the compiler to keep it as a separate function instead of folding it into its caller.

When main calls handle_request, which calls lookup, the program keeps a small record for each call in a region of memory called the call stack, and each record is a frame. If we froze the program while it ran lookup, the stack would read main, handle_request, lookup: the answer to "how did we get here?". That list is what a profiler wants for each sample.

Getting the list takes some help from the program. The CPU's registers are a handful of named storage slots inside each core. One of them, the program counter (PC), holds the address of the instruction being executed, which tells us which function we're in. The callers are harder. When a function starts, it saves two words in its frame: the address of its caller's frame, and the return address, the spot in the caller where execution resumes when the function returns. Then it points a register at that pair. This register is the frame pointer, called x29 on arm64 and rbp on x86-64. Every frame therefore links to its caller's frame, like a linked list running down the stack, and walking the stack means following the list.

The scene below shows one sample being taken, with x29 pointing into the stack.

One sample of lookup(), taken with frame pointers
Your programon a coreKernel perf handlerStack in memoryone frame per callRing buffershared with perfperf.dataon diskPCin lookupx29→ lookup framemainframehandle_requestframelookupframesample1 frame
Step 1. The program is inside lookup(), called by handle_request(), called by main(). The stack has one frame per call. The PC says we're in lookup, and x29 points at its frame.
1 / 7

Notice how little a sample costs: the interrupt, then two memory reads per frame. The stack walk is the biggest part of that cost, and it is also the fragile part, because it only works if every function on the stack kept its link.

5.3The walker in the kernel

Here is the code the kernel runs for the stack walk of a user program on arm64. It's short because the frame-pointer layout is so simple.

arch/arm64/kernel/stacktrace.c
torvalds/linux @ v6.10 ↗
C
struct frame_tail {
	struct frame_tail	__user *fp;
	unsigned long		lr;
} __attribute__((packed));
 
static struct frame_tail __user *
unwind_user_frame(struct frame_tail __user *tail, void *cookie,
	       stack_trace_consume_fn consume_entry)
{
	struct frame_tail buftail;
	/* ... */
	pagefault_disable();
	err = __copy_from_user_inatomic(&buftail, tail, sizeof(buftail));
	pagefault_enable();
	/* ... */
	lr = ptrauth_strip_user_insn_pac(buftail.lr);
 
	if (!consume_entry(cookie, lr))
		return NULL;
 
	/*
	 * Frame pointers should strictly progress back up the stack
	 * (towards higher addresses).
	 */
	if (tail >= buftail.fp)
		return NULL;
 
	return buftail.fp;
}
 
void arch_stack_walk_user(stack_trace_consume_fn consume_entry, void *cookie,
					const struct pt_regs *regs)
{
	/* ... */
		tail = (struct frame_tail __user *)regs->regs[29];
		while (tail && !((unsigned long)tail & 0x7))
			tail = unwind_user_frame(tail, cookie, consume_entry);
	/* ... */
}

struct frame_tail is the saved pair from the scene, fp for the caller's frame and lr for the return address. The loop at the bottom starts at x29 (regs->regs[29]), copies the two words, records the return address, and repeats until the chain stops going up the stack. The copy runs with page faults disabled. A page fault happens when a program touches memory the kernel hasn't loaded yet, and handling one can mean waiting for the disk. Code running inside an interrupt must never wait, so if a frame isn't in memory the copy fails and the walk stops there.

This only works if the program kept the links. GCC and Clang omit frame pointers at -O2 on many targets, because it frees a register for other uses. Gregg's history traces the default to a 2004 GCC change made for 32-bit x86, where the register was scarce. What does the walker do when a function never saved its link?

5.4What a broken stack does to a profile

Without a link, x29 holds whatever the function last used it for, and the walker follows it anyway. It skips frames, or reads garbage, or stops early. Here is the same sample taken in a program built without frame pointers.

The same sample, without frame pointers
Stack in memoryWhat perf recordsleaf firstmainlink savedhandle_requestlink savedlookuplink savedlookuphandle_requestmain_start
Step 1. With frame pointers, as in the last scene, every frame links to its caller. The sample in lookup records the full chain: lookup, handle_request, main.
1 / 5

To check, we can build the toy program twice with GCC 13.3 at -O2, once with -fno-omit-frame-pointer and once with -fomit-frame-pointer, and profile both. The flags in the commands: -O2 turns on optimisation, -g adds debug information, and the two -f flags choose whether frame pointers are kept. perf record -F 999 -g samples 999 times a second (-F), ten times the usual rate because the toy program only runs for a few seconds, and records call stacks (-g); -o names the output file, and the command to profile comes after --. The script collapse.py is a small helper that joins each sample's stack into one line, outermost function first, followed by a count of how many samples had that exact stack. Section 6 explains that format.

Profile the same program with and without frame pointers
shell
Shell
gcc -O2 -g -fno-omit-frame-pointer -o svc_fp   svc.c
gcc -O2 -g -fomit-frame-pointer    -o svc_nofp svc.c
perf record -F 999 -g -o fp.data   -- ./svc_fp 400000
perf record -F 999 -g -o nofp.data -- ./svc_nofp 400000
perf script -i fp.data | ./collapse.py | head -3
perf script -i nofp.data | ./collapse.py | head -3
output
Output
_start;[unknown];[unknown];main;handle_request;lookup 1281
_start;[unknown];[unknown];main;handle_request;parse 830
_start;[unknown];[unknown];main;handle_request;format;[unknown];... 169
 
_start;[unknown];handle_request;lookup 1439
_start;[unknown];handle_request;parse 828
_start;[unknown];format;[unknown];[unknown];[unknown];[unknown] 181

The first three lines are the run with frame pointers. Each line reads left to right from the outermost function to the one that was running, with the sample count at the end. With frame pointers, all 2,458 samples had main and handle_request in their stack. The second three lines are the run without them, and there only 2 of 2,649 samples had main, while 382 samples (14%) showed format with no handle_request above it. The [unknown] frames are library code without symbols installed, which is a different problem (section 6.5).

Even the broken profile has the right leaf functions. What it gets wrong is the ancestry: format looks as if something other than handle_request called it. In a real service with fifty callers of a JSON encoder, that's the difference between "the encoder is slow" and "the encoder is slow when the audit endpoint calls it".

5.5The other ways to walk a stack

Frame pointers aren't the only way to recover the chain, and each alternative has a price. Three terms appear in the table. DWARF is the standard format for debug information, and it includes tables that say how to undo each function's changes to the stack. LBR (last branch record) is a CPU feature that logs the most recent jumps the program took. A JIT (just-in-time compiler, as in the JVM and Node.js) builds machine code while the program runs, so its code has no debug information on disk.

MethodHow it worksCostWhere it breaks
Frame pointersFollow saved fp/lr pairsA few loads per frame; one register given upAny code built without them, including some JIT and hand-written assembly
DWARF (--call-graph dwarf)Copy a chunk of raw stack per sample; unwind later with debug infoLarge perf.data, slow post-processingStacks deeper than the copied chunk; JIT code has no DWARF
LBR (--call-graph lbr, Intel)Hardware records recent branchesNearly freeLimited depth (tens of entries); needs hardware support, often not exposed in VMs
ORCKernel-only unwind tables generated at build timeCheapKernel only
SFrameA compact user-space format modelled on ORCCheapNeeds toolchain and kernel support, still arriving

ORC and SFrame are the same idea as DWARF's tables in a smaller, faster form. For a profiler you want running all the time, the cost column decides, so it's worth seeing what DWARF's cost looks like.

DWARF mode on the same frame-pointer-less binary recovered main in 2,144 of 2,475 samples (87%). It paid for that in file size: perf.data was 21.2 MB against 0.32 MB for the frame-pointer run of the same length, roughly 66× larger, because each sample carries a copy of the raw stack.

?So why did distributions turn frame pointers back on?

Because the cost turned out small and the benefit large. Fedora 38 added -fno-omit-frame-pointer to its default build flags (change page), and Ubuntu 24.04 did the same for 64-bit platforms, measuring a penalty "between 1-2% in most cases" (Canonical). Gregg reports overhead at Netflix "usually less than one percent".

We now have thousands of samples, each one a stack. Nobody can read that as text, so we need a way to see the whole profile at once.

06Reading a flame graph

6.1From samples to a picture

A profile of a real service has tens of thousands of distinct stacks. Gregg invented the flame graph in 2011 while debugging MySQL, because profiler text output was a wall nobody could read (flamegraphs.html). The first step is the format you saw in section 5.4: one line per distinct stack, with the frames joined by semicolons, outermost first, then a count. Producing it is called folding the stacks. The frame-pointer profile from section 5.4, trimmed of the start-up frames above main and with every stack under each phase added together, folds to three lines:

Output
main;handle_request;lookup   1281
main;handle_request;parse     830
main;handle_request;format    347

The scene below does the folding on ten samples of the toy handler, a number small enough to draw. In the real profile there were 2,458.

Ten samples of the handler, folded into a flame graph
Samples, in the order they arrivedSorted by stack nameFlame graphone box per run of identical stackslookup#1parse#2lookup#3format#4lookup#5parse#6lookup#7lookup#8format#9parse#10format2 · 20%lookup5 · 50%parse3 · 30%
Step 1. Ten samples, labelled by the function that was running. Every one is really a whole stack, main → handle_request → that function. The number is the order the sample arrived in.
1 / 4

The real flame graph has the same shape. handle_request is 100% wide and splits into lookup (52%), parse (34%) and format (14%), the shares of 1,281, 830 and 347 out of 2,458 samples.

?Why care about the folded format?

Because it's a universal interchange. perf, bpftrace, async-profiler for the JVM, py-spy and Go's pprof can all produce folded stacks, and once you have them you can grep, diff and sum them with shell tools. Two profiles folded the same way can be subtracted line by line, which is how the before-and-after graphs of section 6.4 are made.

6.2What the axes mean

The scene used each part of the picture in a particular way, and it's worth stating them together.

ElementMeaningNot
BoxOne function in one stackA single call
WidthHow many samples had this frame: its share of CPUDuration of one call
y-axisStack depth; each box's parent is its callerTime
x-axisStacks "sorted alphabetically (it is not the passage of time)"Time order
Top edgeWhat was on-CPU
ColourUsually random warm hues, or by type (kernel, JIT, user)Heat or cost

Sorting is the reason identical stacks merge. If samples were placed in time order, the same call path would appear in hundreds of thin slivers, but sorted by name every sample of main;handle_request;lookup sits together and they merge into one wide box. The price is that the x-axis carries no time information. Two boxes side by side didn't run one after the other, and for a time-ordered view you need a flame chart (Chrome DevTools and Perfetto draw them) instead, which is a different picture.

Speedscope's Time Order view of one MediaWiki page request: coloured function boxes hanging down from the top, with a millisecond ruler along the top edge
A flame chart of one MediaWiki page request in Speedscope's Time Order view. Here the x-axis is time (the ruler runs from 40 to 80 ms), the stacks hang downwards, and a function called twice appears twice. The Left Heavy tab at the top merges identical stacks instead, which turns the same profile into a flame graph.Screenshot: Milaziggy, CC0, via Wikimedia Commons

6.3How to read one in five minutes

  1. Find the widest towers near the top. A wide box whose children are narrow is a function doing work itself. That's your target.
  2. Read plateaus, not peaks. Tall thin spikes are deep call chains that run rarely. They catch the eye, but a narrow box owns few samples, so it rarely matters.
  3. Search. The SVG that Gregg's FlameGraph tools draw (section 11.1) is interactive and has a search box. Searching malloc, lock or memcpy highlights and totals every match across all stacks.
  4. Check the ancestry of a hot leaf. The same leaf under two different parents is two different problems.
  5. Look for what shouldn't be there. Logging, regex compilation, exception construction, JSON re-parsing and TLS handshakes are common surprises.
A wide orange and red flame graph of MediaWiki with ApiMain::execute and MediaWiki::run along the bottom, many towers, one very tall thin spike on the right, and a tooltip reading Parser::callParserFunction (422 samples, 7.11%)
One hour of CPU samples from Wikimedia's servers in December 2014, as a flame graph. The bottom rows are the two entry points, API requests on the left and page views on the right. The broad plateaus of Parser and template-expansion frames on the left are where the time goes. The tallest spike, on the right, is a deep chain that is only a sliver wide, so it owns few samples however much it catches the eye. Hovering gives the count for one frame: Parser::callParserFunction, 422 samples, 7.11%.Screenshot: Tilman Bayer, CC BY-SA 4.0, via Wikimedia Commons

6.4Off-CPU and differential flame graphs

A CPU flame graph can't answer two questions we met earlier, so there are two variants.

The first question is where the 195 ms of waiting from section 3 went. An off-CPU flame graph records a stack every time a thread blocks, weighted by how long it stayed blocked, so width becomes time spent waiting on a lock, a read or a sleep. It's collected at the scheduler's sched:sched_switch tracepoint (a hook the kernel provides at the moment it switches threads) with tools such as offcputime. These are built on eBPF, a way to run small, checked programs inside the kernel at hooks like that one (chapter 48 covers how).

The second question is what got slower in this release. A differential flame graph compares two profiles, before and after a change, and colours each frame by whether it grew or shrank. Instead of eyeballing two graphs side by side, you see the frames that changed in red and blue.

You can combine on-CPU and off-CPU into one graph, but the units differ. An on-CPU sample is CPU time on one core, and an off-CPU sample is waiting time for one thread. With 200 threads mostly idle in a pool, the off-CPU graph is dominated by idle waiting that's perfectly healthy, so filter to the threads that serve requests before reading it.

6.5Symbols: JITs, containers and stripped binaries

A symbol is the name that goes with an address in a program, and the profiler needs symbols to print lookup instead of 0x4012a8. When a flame graph is full of [unknown], the stacks are usually fine and the names are what's missing. JIT-compiled code creates its machine code while the program runs, so no file on disk holds its names.

SymptomCauseFix
JVM frames all [unknown] or Interpreterperf can't read JIT-compiled code's namesasync-profiler, or -XX:+PreserveFramePointer plus a perf-PID.map agent
Node.js frames missingSame JIT problemnode --perf-basic-prof
Everything in a container [unknown]perf on the host can't see the binary at the path the container usedRun perf inside the container, or use --symfs
libc frames [unknown]No debug symbols installedInstall the -dbg or -debuginfo package, or use a debuginfod server
Stacks end after one or two framesNo frame pointersSection 5.5

The third row can happen the other way round too. A program profiled from inside a container produced a profile that was all [unknown], because its binary sat on a host-mounted path that perf report couldn't open from there. Copying the binary to a local path fixed every symbol.

A flame graph tells us where the cycles go. It doesn't tell us why a function needs so many of them.

07Why the CPU is slow in that function

Suppose the flame graph says lookup owns half the CPU. There are several very different ways to make it cheaper, and the right one depends on what the core is doing during those cycles. For that, the CPU's performance monitoring unit (PMU) counts events in hardware: cycles, instructions, cache misses and branch mispredictions. A cache miss means the data wasn't in the CPU's small fast cache and had to be fetched from slower memory (chapter 02). A branch misprediction means the CPU guessed wrongly which way an if would go and threw away work (chapter 01).

7.1IPC: the first number to read

perf stat runs a command and prints counter totals. Read instructions per cycle (IPC) first. It says how many instructions the core finishes, or retires, per clock tick, on average.

IPCWhat it usually meansWhere to look
Below about 1The core is mostly stalled, waiting on memory or on mispredicted branchesCache misses, data layout (chapter 02)
Well above 1The core is retiring several instructions a cycleFewer instructions: better algorithm, less work

Modern cores can retire four or more instructions a cycle, so an IPC of 0.5 means most of the machine is idle while it waits. Making such code do fewer instructions barely helps. Making its memory accesses hit the cache does.

7.2Top-down analysis

Ahmad Yasin's top-down method (ISPASS 2014) starts from how a core works. A core is built like an assembly line, called the pipeline: one end fetches and decodes instructions, the other end executes them, and on every cycle the line has a few slots where a new instruction could enter. Top-down sorts every one of those slots into four buckets:

BucketThe slot was…Typical cause
RetiringUsed by an instruction that completedGood; reduce instruction count to go faster
Bad speculationUsed by work thrown awayBranch mispredictions (chapter 01)
Frontend boundEmpty because instructions weren't fetched and decoded in timeLarge code, instruction-cache misses
Backend boundEmpty because execution was waitingCache misses, long-latency operations

On Intel, perf stat --topdown and toplev compute these from the counters. The method's value is that it tells you which of four very different fixes to try before you try any of them.

A grid of clock cycles 0 to 8 against four pipeline stages, fetch, decode, execute and write-back, with four coloured instructions moving diagonally through it and crossed-out empty slots around them
A textbook four-stage pipeline. Each column is a clock cycle and each row a stage, so an instruction moves one stage down per cycle while the next one enters behind it. The crossed boxes are slots with nothing in them, here only while the pipeline fills and drains. A real core has several slots per stage, and top-down analysis asks of each one whether it retired an instruction, did wasted work, or sat empty, and why.Image: Cburnett, CC BY-SA 3.0, via Wikimedia Commons

7.3When the counters aren't there

Many virtual machines don't pass the PMU through to the programs inside them, and containers running on those machines inherit the gap. The command below asks perf stat for the hardware counters and for two software ones, while running a small Python one-liner, in a container backed by a virtual machine.

Ask for hardware counters inside a VM-backed container
shell
Shell
perf stat -e cycles,instructions,cache-misses,task-clock,context-switches \
  -- python3 -c "sum(range(10**6))"
output
Output
   <not supported>      cycles
   <not supported>      instructions
   <not supported>      cache-misses
             22.47 msec task-clock      #    0.992 CPUs utilized
                 0      context-switches

The three hardware events print <not supported>, because the virtual machine doesn't pass the PMU through. The software events, task-clock and context-switches, are counted by the kernel and work fine. That's also why perf record still worked in section 5.4. By default it takes a sample each time the cycle counter has counted a set number of cycles, and with no cycle counter available it fell back to a kernel timer, which is the "timer ticks" of the scene in section 5.2.

What do you do then? Profile on hardware where the PMU is exposed (bare metal, or instance types that pass it through), or reason from timings instead: measure the same loop at several working-set sizes and watch for the steps chapter 02 shows at each cache level.

With the target found and the reason understood, we change the code. Then we need to find out whether the change helped.

08Benchmarks you can trust

In section 1 we checked our fix with a stopwatch, and the answer was 449 times faster, hard to argue with. Most real changes are worth 5% or 30%, and that's where measuring goes wrong. A benchmark is an experiment you build to answer one question, usually "is B faster than A?". It's the easiest tool in this chapter to get wrong, because a broken benchmark still prints a number.

8.1The compiler deletes what you don't use

The first trap is the compiler. A compiler translates source code into machine code, and at -O2 it rewrites the program to run faster, as long as the program's output stays the same. Two of its tricks matter here. Dead-code elimination removes work whose result is never used. Constant folding replaces a computation with its answer when the answer can be worked out at compile time. A close relative replaces a whole loop with a formula that gives the same result, so the loop never runs.

Here are three loops, each running N = 100 million iterations and timed in nanoseconds per iteration, compiled with -O2. Loop 1 adds up square roots and never uses the sum. Loop 2 adds the same square roots and prints the sum afterwards. Loop 3 adds up i * i with integers and prints the sum, which has a closed-form formula.

Three loops, one of which the compiler keeps
c
C
// 1: result never used
double s = 0;  for (int i = 0; i < N; i++) s += sqrt((double)i);
// 2: result printed afterwards
double s2 = 0; for (int i = 0; i < N; i++) s2 += sqrt((double)i);
// 3: result printed, but it has a closed form
uint64_t s3 = 0; for (uint64_t i = 0; i < N; i++) s3 += i * i;
output
Output
unused result:   0.000 ns/iter
used result:     0.585 ns/iter (sum=6.66667e+11)
closed form:     0.000 ns/iter (sum=662921401752298880)

Two of the three loops cost nothing. The -O2 assembly contains exactly one loop: loop 1 was deleted because nothing reads s, and loop 3 was replaced by a formula for the sum of squares. Only loop 2 did real work, at about 0.585 ns per iteration, and repeated runs give the same numbers. With optimisation off (-O0) all three loops take between 0.5 and 0.9 ns per iteration, so the zeros come from the compiler and not the loops.

A benchmark that reports zero is easy to spot. The dangerous case is a benchmark where the compiler deletes part of the work and the number looks plausible.

?How do real harnesses prevent it?

By making the result escape somewhere the compiler can't see through. JMH (the Java benchmark harness) passes results to a Blackhole, Google Benchmark has DoNotOptimize, and Rust has std::hint::black_box. They also vary the inputs, so constant folding can't precompute the answer.

8.2The JIT hasn't finished yet

On a JVM, the code you time in the first second isn't the code you'll run in production. It starts in the interpreter, which executes it slowly one step at a time. OpenJDK's JVM has two JIT compilers. Once a method has run often enough to count as hot, the quick one, called C1, compiles it into reasonable machine code. If it stays hot, the slower and more thorough one, C2, compiles it again with heavy optimisation. A benchmark that stops its clock early measures the slow versions.

This loop hashes a 10,000-element array 200 times per batch, for twenty batches, in one JVM, repeated across seven runs:

BatchFastest of 7 runsMedian of 7 runs
16.2 ms7.2 ms
21.7 ms4.7 ms
101.8 ms2.3 ms
201.7 ms1.8 ms
20, interpreter only (-Xint)77 ms92 ms

These are OpenJDK 21 numbers, taken on a busy shared host, so the medians are noisy (section 8.3 is about that). Even the fastest runs show the pattern: the first batch was roughly 3.6× slower than steady state, and interpreted code roughly 45× slower.

How a naive JVM benchmark measures the wrong thing
Your timing loopJVMJIT compilercall work() ×200hot method: compileC1 code installedstill hot: optimiseC2 code installedwork deleted, if unused
Step 1. You start the clock and call the method. The JVM runs it in the interpreter, the slowest tier.
1 / 6

8.3Run-to-run noise

Even a program that never changes takes different times from one run to the next. The same binary from section 5.4, run eleven times back to back on a shared container, took between 2.8 and 5.8 seconds. Nothing changed between runs except what the other tenants, the other programs and customers sharing that hardware, were doing.

How does anyone benchmark on a shared machine? By controlling what they can and reporting the rest honestly. In the table, taskset pins a program to chosen cores, and the governor is the kernel setting that decides how fast the CPU clock runs:

Source of noiseWhat it doesControl
Other tenants, other processesSteals CPU, cache and memory bandwidthDedicated hosts; pin with taskset; interleave A and B runs
CPU frequency scaling and turboThe same work takes different timeFix the governor; or measure cycles, not seconds
Thermal throttlingLater runs slower than early onesInterleave A and B, don't run all of A then all of B
Memory layoutAlignment changes cache and branch behaviourRandomise layout (next section)
Timer resolutionShort operations round to zeroTime batches, not single calls

For CPU-bound microbenchmarks, Chen and Revels argue for the minimum as the estimator, since noise only ever adds time. For anything a user waits on, report the distribution (p50, p99, max), because the noise is part of what the user experiences.

8.4Measurement bias

Noise is random and you can average it away. Some errors don't average away, because they're the same on every run. Mytkowicz, Diwan, Hauswirth and Sweeney's ASPLOS 2009 paper, Producing Wrong Data Without Doing Anything Obviously Wrong!, is the one to read before you trust a 5% result.

They changed things that shouldn't matter: the size of the UNIX environment (an unused environment variable) and the order object files were linked. Changing the environment size alone changed a program's run time "frequently by about 33% and once by almost 300%", because the environment sits above the stack and shifts every stack variable's alignment.

That matters for your A/B test because the bias can flip the conclusion. Across link orders, the measured speedup of -O3 over -O2 for perlbench (the Perl benchmark in the SPEC suite) ranged so widely that "we may think we have a 7% slowdown when in fact we have a 8% speedup". The username is stored in the environment, so your optimisation can win or lose depending on who ran the benchmark.

Their remedy is setup randomisation: run each variant under many environments and layouts and compare distributions, so no single lucky layout decides the result.

8.5Microbenchmarks versus the real thing

A microbenchmark times one small piece of code in isolation. A macrobenchmark, or load test, drives the whole service with realistic traffic. They answer different questions. Two abbreviations in the table: GC is garbage collection, the pauses a runtime takes to free memory, and L1 is the CPU's smallest, fastest cache.

MicrobenchmarkMacrobenchmark / load test
QuestionIs this function faster?Does the service meet its latency target at this load?
StrengthIsolates one change; fast to iterateIncludes contention, GC, I/O, the network
Classic lieData fits in L1; branch predictor learned the patternLoad generator hides stalls (coordinated omission)
ReportMinimum or median, with spreadLatency percentiles at a fixed offered rate

A function that got 30% faster on a 4 KB input might well get slower on production inputs that don't fit in cache, so always finish with the macro test.

Load tests have their own traps. In coordinated omission, the load generator stops sending requests while the server is stalled, so the stall never shows up in the numbers. It's covered in chapter 16, and running load tests for capacity is in chapter 42.

Every profiler and benchmark in this chapter also costs something to run, and that cost decides which of them you can afford to leave on all the time.

09What measuring costs

Profiling takes something from the system it watches: CPU for the samples and disk for the files. Here are the numbers from the sections above, side by side.

99 samples/s per core
Sampling rate for a profile you can leave running
a design choice; see section 5.1 for why not 100
1–2%
Frame pointers kept in every build: CPU overhead
Ubuntu's measurement across most workloads; Netflix reports usually under 1%
0.32 MB
perf.data size with frame pointers
for the run in section 5.5
21.2 MB
perf.data size with DWARF unwinding, same run length
each sample carries a copy of the raw stack
≈ 66×
Ratio
from the two rows above

Now put the overhead next to what a profile can find. Say a service uses 500 cores' worth of CPU. Turning on frame pointers costs 1% to 2% of that, and a profile then finds a function that owns 10% of the CPU and can be made twice as fast. Section 2.2 says that saves 5%.

CPU the service uses (an example)assumed500 cores
Cost of frame pointers, 1% to 2%500 × 0.01 to 500 × 0.025 to 10 cores
Fix a function owning 10%, twice as fast: a 5% saving500 × 0.0525 cores
one profile-guided fixpays for frame pointers 2.5× to 5× over

Always-on profiling is cheap enough to leave running only if the stack walk is cheap, which means frame pointers. DWARF unwinding gives nearly the same ancestry for about 66 times the data per sample, so disk space that holds a year of frame-pointer profiles holds less than a week of DWARF ones.

10Keeping it fast

A fix that works today can be undone by next month's release. Most performance problems are introduced one small release at a time, so the cheapest time to catch one is the day it lands.

10.1Benchmarks in CI

Run a small benchmark suite on every merge, on the same dedicated machine, and compare against a stored baseline. Flag a change only when the difference is larger than the run-to-run spread that machine shows.

?Why not a fixed threshold like 5%?

Because the noise floor differs per benchmark (section 8.3). A test whose runs spread by 8% will alarm constantly at a 5% threshold, and one that spreads by 0.5% will hide a 4% regression. Compare each benchmark against its own measured spread, with a statistical test such as Mann-Whitney U, which doesn't assume a normal distribution.

10.2Continuous profiling in production

Sampling at 99 Hz costs little enough to leave on. Continuous profilers (Google-Wide Profiling, Parca, Pyroscope, Datadog's and others) sample every host all the time and store the folded stacks. When a release regresses, you diff this week's fleet profile against last week's, which is the differential flame graph of section 6.4 applied to a whole fleet.

A dark Grafana Pyroscope flame graph for grafana-agent with a tooltip showing regexp.(*Regexp).tryBacktrack at a 39.54% share of CPU
A CPU profile of the Grafana agent collected by a continuous profiler, Grafana Pyroscope. The tooltip shows one regular-expression function, regexp.(*Regexp).tryBacktrack, holding 39.54% of all the CPU in the profile, under the stage of the agent's log pipeline that runs regexes on each line. Summed over many hosts and many hours, a cost like that stands out even if no single host looks busy.Screenshot: EmperorPenguin996, CC0, via Wikimedia Commons

The frame-pointer argument from section 5.5 is what makes this practical. Without frame pointers, always-on profiling means DWARF unwinding on every host, and that section measured what DWARF does to data volume.

11Working through a slow service

11.1Commands, by the question they answer

Each question this chapter raised has a command that answers it on a running Linux machine.

Shell
# Which endpoint, and since when? (section 4.1)
# RED comes from your metrics system: request rate, error rate and p50/p99 duration per endpoint.
 
# Is a resource saturated? (sections 4.2 and 4.3)
dmesg -T | tail                  # errors first
vmstat 1                         # r above the CPU count means a queue for cores
cat /proc/pressure/cpu           # PSI: time stalled waiting for CPU
iostat -xz 1                     # %util, queue size and await for disks
nstat                            # retransmits, listen overflows
 
# Is the container being throttled? (section 4.4)
cat /sys/fs/cgroup/cpu.max /sys/fs/cgroup/cpu.stat
 
# Which code is using the CPU? (sections 5 and 6)
perf record -F 99 -g -- ./myprogram
perf script | ./stackcollapse-perf.pl | ./flamegraph.pl > flame.svg
perf record -F 99 --call-graph dwarf -- ./myprogram     # if frame pointers are missing
 
# Why is that code slow? (section 7)
perf stat -e cycles,instructions,cache-misses -- ./myprogram

stackcollapse-perf.pl and flamegraph.pl come from Gregg's FlameGraph repository. The first folds the output of perf script into the format of section 6.1, and the second draws the SVG.

11.2From complaint to fix

  1. Write down the goal and the number. "p99 of /report from 180 ms back under 100 ms at 2,000 req/s." (Section 2.)
  2. RED on the service. Which endpoint, since when, correlated with which deploy or traffic change? (Section 4.1.)
  3. USE on the hosts and pools behind it. Errors first, then saturation. In containers, check throttling. (Sections 4.2 to 4.4.)
  4. On-CPU or off-CPU? Compare CPU time to wall time for slow requests. (Section 3.)
  5. Profile the right kind. CPU flame graph for on-CPU; off-CPU graph or lock and pool metrics for waiting. (Sections 5 and 6.)
  6. Form one hypothesis and test it with one change. A benchmark if it's a function, a load test if it's the service. (Section 8.)
  7. Confirm in production with the same RED metric you started from.

11.3Rules that hold up

  1. Measure before you change anything, and rank candidates by the share of time they own.
  2. Compare CPU time to wall time before you open a CPU profiler.
  3. Check saturation first on every resource, including pools, locks and container CPU quotas.
  4. Build with frame pointers so production profiles have ancestry.
  5. Attach conditions to every benchmark number: hardware, flags, input size, warm-up, repetitions and spread.
  6. Interleave A and B runs, and report distributions for anything a user waits on.
  7. Finish with a macro test at production input sizes before calling a change a win.

11.4What you trade for what

You getYou payWhen the bill arrives
Profiles with ancestry (frame pointers)1% to 2% CPU, and a build flag in every serviceAs a small, permanent overhead
Exact timings from a tracing profilerA program slowed enough that the timings changeAs a profile that misleads you
Cheap always-on profiling (sampling)Statistical answers: rare functions barely show upWhen a rare, slow path is the problem
Reproducible benchmarks on a dedicated hostHardware that mostly sits idleAs a CI bill
Fixed regression thresholdsFalse alarms on noisy tests, misses on quiet onesAs alert fatigue or a slow leak of speed

11.5Symptom, cause, fix

SymptomLikely causeFix
Latency up, CPU flame graph unchangedTime is off-CPU: locks, I/O, poolsOff-CPU profile; pool and lock metrics
Container slow, node CPU looks idleCPU quota throttlingCheck cpu.stat; raise or remove the limit; smooth bursts
Flame graph stacks one or two frames deepNo frame pointersRebuild with -fno-omit-frame-pointer, or --call-graph dwarf short-term
Profile all [unknown]Symbols unavailable: JIT, container path, strippedasync-profiler, perf maps, --symfs, debug packages
Benchmark says 0 ns, or implausibly fastDead-code elimination or constant foldingBlackhole / DoNotOptimize; varied inputs
JVM benchmark improves every runJIT warm-up inside the measurementJMH with warm-up iterations and forks
A/B result flips between machines or daysNoise or measurement biasInterleave runs, randomise setup, report spread
Fast function, slow serviceMicrobenchmark didn't match production data or contentionMacro load test at production input sizes
Low IPC in a hot loopStalled on memoryImprove locality; see chapter 02

12Summary

  1. Measure before you change anything. The profile of the report put 99.8% of the time in one loop, and fixing that loop made it about 449 times faster.
  2. Name the goal first. Latency, throughput and efficiency pull in different directions, and "faster" has to mean one of them.
  3. Amdahl caps every optimisation. Doubling the speed of a 10% slice saves 5%, so rank work by share of time.
  4. Split on-CPU from off-CPU time. A CPU profiler can't see a thread that's waiting, and in the example request 195 of 200 ms were waiting.
  5. RED finds the service, USE finds the resource. Saturation is the column that predicts latency.
  6. Containers hide a saturation. In the example container, 41% of CPU periods were throttled while the node had idle cores.
  7. A sampling profiler is an interrupt plus a stack walk, and the walk follows frame pointers, a linked list the compiler may not have built.
  8. Without frame pointers the ancestry breaks. In the toy profile, main appeared in 2 of 2,649 samples instead of all of them. DWARF recovered 87% at 66× the data.
  9. A flame graph's width is share of samples, and its x-axis isn't time. Read wide plateaus, check a hot leaf's callers, and diff two profiles to find a regression.
  10. Compilers and JITs rewrite benchmarks. Unused results are deleted, closed forms are folded, and cold JVM code ran 3.6× slower than warm.
  11. Shared machines and innocent setup changes bias results. Interleave runs, randomise setup, and report spread with every number.

13Build this

A profiling and benchmarking kit for one service.

  • Rebuild a service you own with frame pointers, profile it under load with perf record -F 99 -g, fold the stacks, and draw a flame graph. Then do it without frame pointers and compare the ancestry of its top three leaves.
  • Write a script that prints a USE pass for one host: vmstat, PSI, iostat, nstat, and the cgroup's cpu.stat throttling ratio.
  • Take one hot function from the flame graph and benchmark it with a real harness (JMH, Google Benchmark, criterion). Break it on purpose three ways, with an unused result, no warm-up and a cache-resident input, and record how far each moves the number.
  • Make a change, profile before and after, and produce a differential flame graph that shows it.

14Interview questions

beginnerWhat's the USE method, and how is it different from RED?›

USE checks every resource for utilisation, saturation and errors: CPUs, memory, disks, NICs, and software resources such as pools and locks. RED checks every service endpoint for rate, errors and duration. RED tells you which service is hurting users and since when, and USE tells you which resource it's waiting on. Saturation is usually the most telling column, because it measures the queue directly.

beginnerWhy sample at 99 Hz instead of 100 Hz?›

To avoid sampling in lockstep with periodic activity. Lots of software runs on 10 ms timers, and sampling at exactly 100 Hz could keep catching the same phase of that timer and skew the profile.

intermediateHow do you read a flame graph?›

Each box is a function in a stack, its width is the share of samples containing it, and its parent is its caller. The x-axis is sorted alphabetically so that identical stacks merge, which means left-to-right order says nothing about time. Look for wide boxes near the top with narrow children (functions doing work themselves), check the ancestry of hot leaves, and search for things like malloc or lock to total them across stacks.

intermediateYour flame graph shows stacks that are only one or two frames deep. Why?›

Probably missing frame pointers. The kernel's default stack walk follows saved frame-pointer and return-address pairs, and code built with -fomit-frame-pointer breaks the chain. In the toy profile of section 5.4, only 2 of 2,649 samples reached main without frame pointers, against all of them with. Fix it by rebuilding with -fno-omit-frame-pointer (Fedora 38 and Ubuntu 24.04 now do this by default), or use --call-graph dwarf at the cost of much bigger data: about 66× in that profile.

intermediateA service in Kubernetes has high p99 but the node's CPU is 40% idle. What do you check?›

CPU throttling from the container's CPU quota. Read nr_throttled against nr_periods in the cgroup's cpu.stat, or the cAdvisor throttling metric. Bursty work can use a pod's whole quota early in each 100 ms period and then wait, which adds latency while the node looks idle. Then check off-CPU time and pools, since a CPU profile won't show either.

deepYour microbenchmark says the new version is 20% faster. What would make you not believe it?›

Dead-code elimination or constant folding (is the result consumed, are inputs varied?). JIT warm-up inside the timed region. An input that fits in cache when production data doesn't. Noise, if the difference is within run-to-run spread. And measurement bias from layout, which Mytkowicz et al. showed can move results by tens of percent and flip a conclusion. I'd want interleaved runs, a real harness, the spread, and a macro test at production input sizes.

deepWhen is a CPU profile the wrong tool?›

When the time isn't on the CPU: locks, I/O, pool waits, throttling, scheduler delay. A blocked thread executes no instructions, so a sampling CPU profiler never sees it. Compare CPU time to wall time for slow requests. If they differ a lot, use off-CPU profiling (stacks at sched_switch, weighted by blocked time), lock contention stats and pool metrics instead.

deepWhat does top-down analysis tell you that a flame graph doesn't?›

The flame graph says where the hot code is, and top-down says why it's slow. It classifies each pipeline slot as retiring, bad speculation, frontend bound or backend bound. Backend bound with low IPC points at memory stalls and data layout, bad speculation at unpredictable branches, frontend bound at code size, and retiring-dominated code at doing fewer instructions. Each needs a different fix, and the counters say which to try first, if the PMU is exposed at all.

15Go deeper

check yourself
Two boxes sit side by side in a flame graph. Did the left one run first?›

No. The x-axis is sorted alphabetically so identical stacks merge. It carries no time information.

Your benchmark reports 0.000 ns per iteration. What happened?›

The compiler removed the work, either because the result was never used or because it computed a closed form. Consume the result through a barrier such as Blackhole or DoNotOptimize, and vary the input.

perf stat prints <not supported> for cycles. Can you still profile?›

Yes, with software events: perf record falls back to a timer-based cpu-clock or task-clock sample. Hardware counters such as IPC need a PMU the VM exposes.

Where does off-CPU time come from in a flame graph?›

From stacks captured when a thread blocks, at the scheduler's switch point, weighted by how long it stayed off the CPU.

Brendan Gregg — The USE Method

The method, the Linux checklist, and the reasoning for software resources. brendangregg.com/usemethod.html.

Read the Linux checklist once, then keep it open during incidents.
Brendan Gregg — The Flame Graph (ACM Queue / CACM, 2016)

The paper describing flame graphs, their variants, and how to read them. Companion page with tools: flamegraphs.html.

Brendan Gregg — The Return of the Frame Pointers (2024)

The history of the 2004 default, the overhead numbers, and the alternatives. Blog post.

arch/arm64/kernel/stacktrace.c (Linux v6.10)

The user-space frame-pointer walker perf uses on arm64, quoted in 5.3. Source.

Mytkowicz et al. — Producing Wrong Data Without Doing Anything Obviously Wrong! (ASPLOS 2009)

Environment size and link order bias SPEC results enough to flip conclusions. ACM DL.

The paper to read before you trust any single-digit speedup.
Chen & Revels — Robust benchmarking in noisy environments (2016)

The statistics behind Julia's BenchmarkTools, and the case for the minimum. arXiv:1608.04295.

Yasin — A Top-Down Method for Performance Analysis (ISPASS 2014)

The four-bucket pipeline breakdown behind VTune and toplev. Paper.

Tom Wilkie — The RED Method

Rate, errors and duration for every service, and how it relates to USE. Grafana blog.

Contention, Queueing & Tail Latency

Why saturation turns into latency, lock convoys a CPU profile can't see, and coordinated omission in load tests. Chapter 16.

Queueing, Capacity & Scaling

Load testing for capacity, sizing a fleet, and autoscalers. Chapter 42.

Memory Hierarchy & Cache Coherence

What a low IPC is usually waiting for, measured from L1 to DRAM. Chapter 02.

eBPF: Running Your Code in the Kernel

How off-CPU profilers and tracing tools attach to the scheduler. Chapter 48.