perf: sampling, flame graphs and off-CPU time

Profile CPU use and read a flame graph.

Advanced16 min · lesson 18 of 21

strace shows what a process asks the kernel for; it cannot tell you which of the program's own functions burn the CPU. perf can. This lesson profiles a small program with a known hot path, reads the result as a report and as a flame graph, shows the ways a profile goes wrong (missing frame pointers, missing symbols, too few samples, interpreted code), and then measures the other half of latency, the time a program spends waiting rather than running. The method is the one this course uses throughout: confirm the symptom, profile, read the picture, change one thing, profile again.

Sampling, and who may use perf

perf is a sampling profiler. It asks the kernel to interrupt the program at a fixed rate, and at every interrupt it records where the program was: the instruction address and, with -g, the whole call stack, the chain of functions that led there. After a few thousand samples, the share of samples that land in a function estimates the share of CPU time spent in it. -F 99 samples 99 times a second per CPU; the odd number avoids running in step with timers that fire at round intervals. Sampling at -F 99 with frame-pointer stacks costs little, because nothing happens between samples, which is why perf can be used on busy systems where strace cannot. Other modes cost more: DWARF call graphs (later in this lesson) copy part of the stack with every sample, and a system-wide recording (-a) grows until you stop it, so bound it (-- sleep 30) and write to a filesystem with room. Reports (perf report, perf script) are CPU-heavy too; on a production host, record there and analyse elsewhere.

deploy@web01 · Ubuntu 26.04 LTS
$ perf version dpkg -S /usr/bin/perf
perf version 7.0.14 linux-perf: /usr/bin/perf
$ sysctl kernel.perf_event_paranoid
kernel.perf_event_paranoid = 4

On Ubuntu 26.04, perf comes from the linux-perf package, installed by default (version 7.0.14). kernel.perf_event_paranoid decides what unprivileged users may measure, and Ubuntu sets it to 4, a level Ubuntu's kernels add to the upstream scale (which ends at 2) and which blocks unprivileged use of perf entirely. So profiling needs sudo on Ubuntu, as the first recording below shows. On RHEL 10 the value is 2 and sudo dnf install perf installs version 6.12; there a user may profile their own processes in user space without sudo.

Profile a program with a known hot path

The workload is a small C program whose answer you know in advance: for every request, handle_requests calls parse_request once and checksum, which walks the buffer three times, so checksum should dominate. noinline keeps the functions separate so they appear in the profile.

~/fg-demo.c
/* fg-demo.c: a CPU-bound program with a known hot path.
main -> handle_requests -> parse_request, then checksum for every request. */
#include <stdio.h>
#include <stdint.h>
__attribute__((noinline)) static uint64_t checksum(const unsigned char *buf, int n)
{
uint64_t h = 1469598103934665603ULL; /* FNV-1a, three passes */
for (int r = 0; r < 3; r++)
for (int i = 0; i < n; i++)
h = (h ^ buf[i]) * 1099511628211ULL;
return h;
}
__attribute__((noinline)) static uint64_t parse_request(unsigned char *buf, int n, uint64_t seed)
{
for (int i = 0; i < n; i++)
buf[i] = (unsigned char)((seed >> (i & 31)) + i);
return seed * 6364136223846793005ULL + 1;
}
__attribute__((noinline)) static uint64_t handle_requests(long count)
{
static unsigned char buf[4096];
uint64_t seed = 42, total = 0;
for (long k = 0; k < count; k++) {
seed = parse_request(buf, sizeof buf, seed);
total += checksum(buf, sizeof buf);
}
return total;
}
int main(void)
{
uint64_t total = handle_requests(300000);
printf("%llu\n", (unsigned long long)total);
return 0;
}

Compile it with optimisation (-O2), debug information (-g, for names and line numbers) and -fno-omit-frame-pointer, which the section on wrong profiles explains. gcc is not installed on Ubuntu Server by default (sudo apt install gcc). time shows the symptom: about five seconds, all of it user CPU time. Recording it as an ordinary user fails on Ubuntu:

deploy@web01 · Ubuntu 26.04 LTS
$ gcc -O2 -g -fno-omit-frame-pointer -o fg-demo fg-demo.c time ./fg-demo
4640583664907633056 real 0m5.127s user 0m5.111s sys 0m0.015s
$ perf record -F 99 -g -- ./fg-demo
… Error: Failure to open event 'cpu/cycles/Pu' on PMU 'cpu' which will be removed. Access to performance monitoring and observability operations is limited. … perf_event_paranoid setting is 4: -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 … Error: Failure to open any events for recording.

The message's advice to lower the setting in /etc/sysctl.conf is generic upstream text (Ubuntu 26.04 has no such file) and a host-wide change that the hardening course (optional) discusses; for a one-off profile, use sudo.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo perf record -F 99 -g -o fg-demo.data -- ./fg-demo
4640583664907633056 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.060 MB fg-demo.data (468 samples) ]
$ sudo perf report -i fg-demo.data --stdio --no-children
… # Samples: 468 of event 'task-clock:ppp' # Event count (approx.): 4727272680 … # Overhead Command Shared Object Symbol … 91.67% fg-demo fg-demo [.] checksum.constprop.0 | ---checksum.constprop.0 handle_requests.constprop.0 printf (inlined) main __libc_start_call_main call_init (inlined) __libc_start_main_impl (inlined) _start … 8.33% fg-demo fg-demo [.] parse_request.constprop.0 …

perf record wrote 468 samples to fg-demo.data (-o names the file; the default is perf.data). The event is task-clock, a software timer, because this virtual machine exposes no hardware counters; on bare metal, and on cloud instances that expose a virtual PMU (performance monitoring unit), perf samples CPU cycles instead. perf report --no-children ranks functions by self time, the samples in which the function itself was running, and the call graph under each entry shows the path that led there, read from the hot function down to _start. checksum has 92% of the samples. The printf (inlined) frame is not a real caller: Ubuntu's gcc enables _FORTIFY_SOURCE, which makes printf an inline wrapper, and perf attributes part of main to it. constprop.0 marks a copy of the function that gcc specialised for constant arguments.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo perf report -i fg-demo.data --stdio --children -g none | grep %
100.00% 0.00% fg-demo fg-demo [.] _start 100.00% 0.00% fg-demo fg-demo [.] handle_requests.constprop.0 100.00% 0.00% fg-demo fg-demo [.] main 100.00% 0.00% fg-demo fg-demo [.] printf (inlined) 100.00% 0.00% fg-demo libc.so.6 [.] __libc_start_call_main 100.00% 0.00% fg-demo libc.so.6 [.] __libc_start_main_impl (inlined) 100.00% 0.00% fg-demo libc.so.6 [.] call_init (inlined) 91.67% 91.67% fg-demo fg-demo [.] checksum.constprop.0 8.33% 8.33% fg-demo fg-demo [.] parse_request.constprop.0

With --children, the first column is the time spent in a function and everything it called, and the second the self time. main and handle_requests cover 100% of the samples but do no work themselves; checksum does its own. That distinction is what a flame graph draws.

From stacks to a flame graph

A flame graph shows every sampled stack at once. Brendan Gregg's FlameGraph scripts do it in two steps: stackcollapse-perf.pl folds each stack into one line of function names joined by semicolons, followed by its count, and flamegraph.pl draws those lines as an SVG. Pin the scripts to a known commit so the output does not change under you. On a production server, run only perf script there and fold and draw on your workstation, instead of cloning tools onto the server.

deploy@web01 · Ubuntu 26.04 LTS
$ git clone -q https://github.com/brendangregg/FlameGraph git -C FlameGraph checkout -q 41fee1f99f9276008b7cd112fca19dc3ea84ac32 git -C FlameGraph log -1 --format="%h %cs %s"
41fee1f 2024-10-21 Merge pull request #264 from hassec/fix_search_null
$ sudo perf script -i fg-demo.data | ./FlameGraph/stackcollapse-perf.pl > fg-demo.folded wc -l fg-demo.folded sort -k2 -nr fg-demo.folded | head -5
2 fg-demo.folded fg-demo;_start;__libc_start_main_impl;call_init;__libc_start_call_main;main;printf;handle_requests.constprop.0;checksum.constprop.0 4333333290 fg-demo;_start;__libc_start_main_impl;call_init;__libc_start_call_main;main;printf;handle_requests.constprop.0;parse_request.constprop.0 393939390
$ ./FlameGraph/flamegraph.pl --title 'fg-demo: on-CPU' fg-demo.folded > fg-demo.svg grep -o '<title>[^<]*</title>' fg-demo.svg | head -8
<title>checksum.constprop.0 (4,333,333,290 samples, 91.67%)</title> <title>__libc_start_call_main (4,727,272,680 samples, 100.00%)</title> <title>_start (4,727,272,680 samples, 100.00%)</title> <title>handle_requests.constprop.0 (4,727,272,680 samples, 100.00%)</title> <title>main (4,727,272,680 samples, 100.00%)</title> <title>all (4,727,272,680 samples, 100%)</title> <title>fg-demo (4,727,272,680 samples, 100.00%)</title> <title>printf (4,727,272,680 samples, 100.00%)</title>

This program has only two distinct stacks. The count at the end of each folded line is not a number of samples but the sum of their sampling periods, in nanoseconds of CPU time for task-clock, so the widths are in proportion to CPU time either way. checksum accounts for 4,333,333,290 of 4,727,272,680 (91.67%). The <title> elements are the tooltips you see when you point at a box in a browser: checksum has 91.67%, and every frame below the two leaves covers 100%.

Reading a flame graph
Width
Share of samples
wider box = more CPU time in it and its callees
Height
Stack depth
bottom = entry point, top = the running function
Order
Left to right is alphabetical
not a timeline; equal stacks merge
A wide top edge is a function busy on its own; a tall narrow tower is deep but cheap.

Read width first: the widest boxes at the top of the graph are the functions doing the work, and a box's width includes everything stacked above it. The x-axis is sorted alphabetically so that identical stacks merge into one box; it says nothing about when something ran. perf itself can also produce a flame graph with perf script report flamegraph, but not with Ubuntu's build: its linux-perf package ships without perf's report scripts. RHEL's perf has them.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo perf script report flamegraph
Please specify a valid report script(see 'perf script -l' for listing)
deploy@rocky10 · Rocky Linux 10.2
$ perf version rpm -qf /usr/bin/perf
perf version 6.12.0-211.60.1.el10_2.aarch64 perf-6.12.0-211.60.1.el10_2.aarch64
$ sysctl kernel.perf_event_paranoid
kernel.perf_event_paranoid = 2
$ gcc -O2 -g -fno-omit-frame-pointer -o fg-demo fg-demo.c
$ perf record -F 99 -g -o perf.data -- ./fg-demo
4640583664907633056 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.049 MB perf.data (452 samples) ]
$ perf report -i perf.data --stdio --no-children -g none | grep %
93.81% fg-demo fg-demo [.] checksum.constprop.0 6.19% fg-demo fg-demo [.] parse_request.constprop.0
$ perf script -l | grep -i flame
flamegraph create flame graphs
$ timeout 60 perf script report flamegraph -i perf.data -- --format json < /dev/null ls flamegraph.* stacks.* 2>/dev/null
dumping data to stacks.json stacks.json

On RHEL an unprivileged user recorded their own program (perf_event_paranoid 2), and perf script -l lists the flamegraph report. Its default output is an interactive flamegraph.html built from the d3-flame-graph template, which it asks to download from cdn.jsdelivr.net unless you pass --allow-download or --template; on a host without internet access the command waits there, so the lab bounded it with timeout and asked for JSON (--format json writes stacks.json). The FlameGraph scripts need nothing but Perl.

When the profile is wrong

A flame graph is only as good as the stacks behind it, and it rarely looks broken when it is wrong. perf walks a user-space stack by following frame pointers: one register, the frame pointer, holds the address of the current function's frame, and each function saves its caller's frame pointer (with the return address) on the stack when it starts. Following those saved values leads from the running function back to _start. Compilers may use the register for other work (-fomit-frame-pointer, which GCC's documentation lists as enabled from -O1), and then the chain breaks. Since Ubuntu 24.04, Ubuntu builds its packages with frame pointers so that profiles of system libraries work; your own builds and third-party binaries decide for themselves. On this aarch64 system plain -O2 still kept the frame records, so the lab asks for the omission explicitly; on x86_64 plain -O2 is enough to lose them.

deploy@web01 · Ubuntu 26.04 LTS
$ gcc -O2 -g -fomit-frame-pointer -o fg-demo-nofp fg-demo.c sudo perf record -F 99 -g -o nofp.data -- ./fg-demo-nofp sudo perf script -i nofp.data | ./FlameGraph/stackcollapse-perf.pl | sort -k2 -nr | head -5
4640583664907633056 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.057 MB nofp.data (514 samples) ] fg-demo-nofp;_start;__libc_start_main_impl;call_init;handle_requests.constprop.0;checksum.constprop.0 4818181770 fg-demo-nofp;_start;__libc_start_main_impl;call_init;handle_requests.constprop.0;parse_request.constprop.0 363636360 fg-demo-nofp;_start;__libc_start_main_impl;call_init;handle_requests.constprop.0;checksum.constprop.0;el0t_64_irq;el0t_64_irq_handler;__el0_irq_handler_common;el0_interrupt;exit_to_user_mode_loop;__rseq_handle_slowpath;rseq_slowpath_update_usr;rseq_set_ids_get_csaddr 10101010
$ sudo perf record -F 99 --call-graph dwarf -o dwarf.data -- ./fg-demo-nofp sudo perf script -i dwarf.data | ./FlameGraph/stackcollapse-perf.pl | sort -k2 -nr | head -5 ls -l fg-demo.data nofp.data dwarf.data
4640583664907633056 [ perf record: Woken up 15 times to write data ] [ perf record: Captured and wrote 3.924 MB dwarf.data (481 samples) ] fg-demo-nofp;[unknown];[unknown];[unknown];__libc_start_call_main;main;handle_requests.constprop.0;checksum.constprop.0 4525252480 fg-demo-nofp;[unknown];[unknown];[unknown];__libc_start_call_main;main;handle_requests.constprop.0;parse_request.constprop.0 333333330 -rw------- 1 root root 4127593 Sep 27 09:22 dwarf.data -rw------- 1 root root 75921 Sep 27 09:22 fg-demo.data -rw------- 1 root root 72937 Sep 27 09:22 nofp.data

Without frame pointers the stacks lose frames and still look plausible: main and __libc_start_call_main are gone, and handle_requests appears to be called straight from the C library's startup code. (The third line is a single sample taken while the CPU was handling an interrupt, so kernel functions sit on top of checksum.) --call-graph dwarf is the fix when you cannot rebuild: perf copies a slice of the stack with every sample (8 KiB by default) and unwinds it afterwards with the debug information, which recovered main here, at the cost of a file more than fifty times larger (4.1 MB against 76 KB). It needs unwind information for every frame, and the three C library startup frames it could not unwind show as [unknown]. The copied stack memory can contain secrets, so treat a DWARF profile from production like a core dump.

RHEL 10 makes the other choice: its packages are built without frame pointers, which the build flags recorded in each package show.

deploy@rocky10 · Rocky Linux 10.2
$ rpm -q --qf '%{OPTFLAGS}\n' glibc | tr ' ' '\n' | grep -e -O2 -e frame -e unwind
-O2 -fasynchronous-unwind-tables

-O2 and -fasynchronous-unwind-tables are there; -fno-omit-frame-pointer is not. On x86_64 RHEL, where -O2 drops the frame pointer, perf record -g of a service that spends time in glibc, OpenSSL or the Python library loses frames in exactly the plausible-looking way shown above. The unwind tables are what --call-graph dwarf needs, so use it there, or --call-graph lbr on bare-metal Intel CPUs, which record recent branches in hardware. The lab's aarch64 Rocky VM profiles only the course's own program, so it could not show the broken case.

deploy@web01 · Ubuntu 26.04 LTS
$ gcc -O2 -fno-omit-frame-pointer -s -o fg-demo-stripped fg-demo.c sudo perf record -F 99 -g -o stripped.data -- ./fg-demo-stripped sudo perf report -i stripped.data --stdio --no-children --sort symbol | grep -v "^#" | grep % | head -5
4640583664907633056 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.061 MB stripped.data (474 samples) ] 91.98% [.] 0x0000000000000928 - - 2.53% [.] 0x00000000000008dc - - 0.84% [.] 0x0000000000000880 - - 0.63% [.] 0x0000000000000848 - - 0.63% [.] 0x0000000000000890 - -
$ strip -o fg-demo-strip2 fg-demo sudo perf record -F 99 -g -o strip2.data -- ./fg-demo-strip2 sudo perf report -i strip2.data --stdio --no-children --sort symbol | grep -v "^#" | grep % | head -5
4640583664907633056 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.060 MB strip2.data (468 samples) ] 95.09% [.] checksum.constprop.0 - - 4.91% [.] parse_request.constprop.0 - -
$ sudo perf record -F 5 -g -o low.data -- ./fg-demo sudo perf report -i low.data --stdio --no-children -g none | grep %
4640583664907633056 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.012 MB low.data (21 samples) ] 95.24% fg-demo fg-demo [.] checksum.constprop.0 4.76% fg-demo fg-demo [.] parse_request.constprop.0

A binary without a symbol table (built with -s here) leaves perf only raw addresses: 92% of the time in 0x928, which tells you nothing. The second result is a surprise: a stripped copy of the binary already profiled still shows names, because perf keeps a copy of every binary it records in the recording user's ~/.debug (root's, with sudo), indexed by build ID, and strip does not change the build ID. Five samples a second over the whole run gives only 21 samples, so each sample is almost 5% of the profile: parse_request is a single sample, and the split of 95/5 cannot tell a function with 3% of the time from one with 8%. Even hundreds of samples move by a few points between runs on this shared VM (92% and 95% for checksum above, from the same program). Aim for hundreds of samples, by running longer rather than sampling faster, and treat a few points of difference as noise until a repeat confirms it.

Interpreted and JIT-compiled code is the last trap. The same hot path in Python:

~/fg-demo.py
# fg-demo.py: the same hot path in Python.
def checksum(buf):
h = 1469598103934665603
for b in buf:
h = ((h ^ b) * 1099511628211) & 0xFFFFFFFFFFFFFFFF
return h
def handle_requests(count):
total = 0
for k in range(count):
total += checksum(bytes((k + i) & 255 for i in range(4096)))
return total
print(handle_requests(6000))
deploy@web01 · Ubuntu 26.04 LTS
$ sudo perf record -F 99 -g -o py.data -- python3 fg-demo.py sudo perf report -i py.data --stdio --children --sort symbol -g none | grep py::
54225052499736698777168 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.051 MB py.data (230 samples) ]
$ sudo perf record -F 99 -g -o pyperf.data -- python3 -X perf fg-demo.py sudo perf report -i pyperf.data --stdio --children --sort symbol -g none | grep py::
54225052499736698777168 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.065 MB pyperf.data (228 samples) ] 99.56% 0.00% [.] py::<module>:/home/deploy/fg-demo.py - - 99.56% 0.00% [.] py::handle_requests:/home/deploy/fg-demo.py - - 67.54% 0.00% [.] py::checksum:/home/deploy/fg-demo.py - - 22.37% 0.44% [.] py::handle_requests.<locals>.<genexpr>:/home/deploy/fg-demo.py - -

perf sees the Python interpreter's C functions, never the Python functions it is running, so the first report has no py:: line at all. Since Python 3.12, python3 -X perf makes the interpreter publish a small trampoline for every Python function and a map file perf reads, and the functions appear: py::checksum is on the stack in 68% of the samples, and the generator expression that builds each request in 22%. -X perf gives complete stacks only when the interpreter itself keeps frame pointers; for interpreters built without them (RHEL's, per the flags above), Python 3.13 added -X perf_jit, which works with --call-graph dwarf (RHEL 10's default python3 is 3.12). Java and Node.js need the same kind of help (a perf map agent, or --perf-basic-prof).

Missing library symbols have one more remedy. Ubuntu sets DEBUGINFOD_URLS in login shells so that tools such as perf and gdb can download debug information from Ubuntu's debuginfod server, but sudo resets the environment, and Ubuntu's sudo-rs ignores -E. Pass the one variable on instead.

deploy@web01 · Ubuntu 26.04 LTS
$ echo $DEBUGINFOD_URLS
https://debuginfod.ubuntu.com
$ sudo -E printenv DEBUGINFOD_URLS
sudo: preserving the entire environment is not supported, '-E' is ignored
$ sudo --preserve-env=DEBUGINFOD_URLS printenv DEBUGINFOD_URLS
https://debuginfod.ubuntu.com

Off-CPU time: where the waiting goes

A CPU profile only sees a program while it runs. The next program is slow for a different reason: it waits 20 ms for a backend before each short piece of work.

~/fg-wait.c
/* fg-wait.c: a worker that spends most of its time waiting for a slow backend. */
#include <stdio.h>
#include <stdint.h>
#include <time.h>
__attribute__((noinline)) static void wait_for_backend(void)
{
struct timespec ts = { 0, 20 * 1000 * 1000 }; /* the backend answers in 20 ms */
nanosleep(&ts, NULL);
}
__attribute__((noinline)) static uint64_t render(uint64_t x)
{
for (int i = 0; i < 2000000; i++)
x = x * 6364136223846793005ULL + 1;
return x;
}
int main(void)
{
uint64_t x = 1;
for (int k = 0; k < 300; k++) {
wait_for_backend();
x = render(x);
}
printf("%llu\n", (unsigned long long)x);
return 0;
}
deploy@web01 · Ubuntu 26.04 LTS
$ gcc -O2 -g -fno-omit-frame-pointer -o fg-wait fg-wait.c time ./fg-wait
16341384966486465025 real 0m8.239s user 0m0.947s sys 0m0.007s
$ sudo perf record -F 99 -g -o wait.data -- ./fg-wait sudo perf script -i wait.data | ./FlameGraph/stackcollapse-perf.pl | sort -k2 -nr | head -3
16341384966486465025 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.019 MB wait.data (84 samples) ] fg-wait;_start;__libc_start_main_impl;call_init;__libc_start_call_main;main;render 808080800 fg-wait;_start;__libc_start_main_impl;call_init;__libc_start_call_main;main;wait_for_backend;__nanosleep;__clock_nanosleep;__internal_syscall_cancel;el0t_64_sync;el0t_64_sync_handler;el0_svc;do_el0_svc;el0_svc_common.constprop.0;syscall_trace_exit;__audit_syscall_exit 10101010 fg-wait;_start;__libc_start_main_impl;call_init;__libc_start_call_main;main;wait_for_backend;__nanosleep;__clock_nanosleep;__internal_syscall_cancel;el0t_64_sync;el0t_64_sync_handler;el0_svc;do_el0_svc;el0_svc_common.constprop.0;invoke_syscall.constprop.0;__arm64_sys_clock_nanosleep;common_nsleep;hrtimer_nanosleep;do_nanosleep;schedule;__schedule;finish_task_switch.isra.0 10101010

8.2 seconds of wall-clock time, 0.9 seconds of CPU. The CPU profile is correct and useless: 84 samples, almost all in render, because a sleeping thread is not running when the samples are taken. Off-CPU analysis records the opposite: every time the scheduler switches the thread out, how long it stayed off the CPU, and the stack at that moment. offcputime-bpfcc from the bcc tools does this in the kernel with eBPF; -p selects the process, -U keeps user-space stacks only, -f prints folded output for flamegraph.pl, and the last argument is the duration in seconds. Its probe fires on every context switch on the host, and -p filters only after it has fired; the tool's man page warns that scheduler events "can exceed 1 million events per second" and asks you to test before production use. On a busy host keep the duration short, and add -m with a minimum block time in microseconds (-m 1000) to skip short waits.

deploy@web01 · Ubuntu 26.04 LTS
$ ./fg-wait > /dev/null & sudo offcputime-bpfcc -U -f -p $! 3 > fg-wait.offcpu wait sort -k2 -nr fg-wait.offcpu | head -3
fg-wait;_start;__libc_start_main_impl;__libc_start_call_main;main;wait_for_backend;nanosleep;__internal_syscall_cancel 2636404 fg-wait;_start;__libc_start_main_impl;__libc_start_call_main;render 107 fg-wait;_start;__libc_start_main_impl;__libc_start_call_main;render 66
$ ./FlameGraph/flamegraph.pl --color=io --countname=us --title 'fg-wait: off-CPU' fg-wait.offcpu > fg-wait-offcpu.svg grep -oE '<title>(main|wait_for_backend|nanosleep|render) [^<]*</title>' fg-wait-offcpu.svg
<title>main (2,636,404 us, 99.99%)</title> <title>wait_for_backend (2,636,404 us, 99.99%)</title> <title>nanosleep (2,636,404 us, 99.99%)</title>

Of the three seconds measured, 2.64 were spent blocked in nanosleep called from wait_for_backend; the count is microseconds, so --countname=us labels the SVG correctly and --color=io uses the blue palette conventional for off-CPU graphs. The fix for this program is not in the CPU at all but in the backend it waits for, or in waiting for several requests at once. On Ubuntu 26.04's kernel several bcc 0.35 tools fail to compile but offcputime-bpfcc works; RHEL installs it as /usr/share/bcc/tools/offcputime. The eBPF tools lesson covers these tools and their bpftrace equivalents.

Try this

Predict before you measure: change checksum to one pass instead of three, rebuild, and record again. With three passes, checksum had 11 to 19 times the weight of parse_request in the lab's runs (92% and 95%), so one pass should give it a third of that, four to six times, a share between 79% and 86%. The lab created the edited copy with sed, so the original stays unchanged.

deploy@web01 · Ubuntu 26.04 LTS
$ sed 's/r < 3/r < 1/' fg-demo.c > fg-demo2.c gcc -O2 -g -fno-omit-frame-pointer -o fg-demo2 fg-demo2.c sudo perf record -F 99 -g -o fg-demo2.data -- ./fg-demo2 sudo perf script -i fg-demo2.data | ./FlameGraph/stackcollapse-perf.pl | sort -k2 -nr | head -3
2667496954586537376 [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.028 MB fg-demo2.data (167 samples) ] fg-demo2;_start;__libc_start_main_impl;call_init;__libc_start_call_main;main;printf;handle_requests.constprop.0;checksum.constprop.0 1434343420 fg-demo2;_start;__libc_start_main_impl;call_init;__libc_start_call_main;main;printf;handle_requests.constprop.0;parse_request.constprop.0 252525250

The two folded counts give the answer: checksum 85% and parse_request 15%, inside the predicted range, and 167 samples instead of 468 show the program finished in about a third of the time. That is the loop to keep: profile, change one thing, profile again, and compare the widths.

Takeaway

Before you trust a flame graph, check that its stacks are whole and named and that it holds hundreds of samples; then read width, not height, and when a program is slow but not busy, measure off-CPU time instead.

Quick check
01A CPU flame graph of a web service shows one tower 40 frames tall but only 2% wide, and next to it a flat box, compress_block, that is 3 frames tall and 55% wide at the top. Where should optimisation start?
Incorrect — Height is call depth, and this tower holds only 2% of the samples, so even removing it entirely saves little.
Incorrect — The x-axis is sorted alphabetically so equal stacks merge; position says nothing about order in time.
Incorrect — Widths come from samples, which estimate CPU time; call counts are not measured by sampling at all.
Correct — Width is CPU time: 55% of the samples ran in compress_block itself, so it is the place to start.
02A profile of an in-house C service built with -O2 shows the hot function directly under __libc_start_main, with main and the request handler missing, although the binary is not stripped. What is the most likely cause and fix?
Correct — Without frame pointers perf's stack walk skips frames, and the result still looks like a plausible stack.
Incorrect — More samples do not repair a broken stack walk; every sample would lose the same frames.
Incorrect — The frames are not unnamed, they are absent; symbols name addresses but cannot add frames the walk never found.
Incorrect — debuginfod supplies debug information for names and DWARF unwinding; the frame-pointer walk does not use it.
03A report job takes 90 seconds, but top shows it at about 5% CPU, and its CPU flame graph is small and shows only a JSON encoder. What should you measure next?
Incorrect — The rate is not the problem; the job is not running most of the time, so no CPU sample can see that time.
Incorrect — Other processes may be busy, but the job's own 85 missing seconds are spent waiting, which CPU sampling never records.
Correct — Most of the 90 seconds are spent off the CPU, and only off-CPU analysis attributes that time to code paths.
Incorrect — Broken stacks look wrong in shape; a small graph with correct stacks means there were simply few samples.

Related