CoursesAdvanced Linux internals & toolingFlame graphs & CPU profiling

Flame graphs & CPU profiling

perf + FlameGraph: see the hot path at a glance.

Advanced14 min · lesson 13 of 17

Your server is pinned at 100 percent CPU (central processing unit, the chip that actually runs your code) and you cannot say why. The top command names the busy process. It says nothing about which lines of code inside that process are eating the cycles. A flame graph answers that question in one picture.

A flame graph works like an itemized receipt for time. A plain profiler hands you a total: the program spent 40 percent of its time in some function. A flame graph itemizes that total by the exact chain of calls that led to it, the way a receipt breaks a big bill into line items so the one expensive thing jumps out. perf (a command-line profiler that ships with Linux and rides on the kernel's built-in sampling machinery) gives you the hot functions as a flat list. A flame graph stacks the whole call path, so you see which function is hot and the exact route the program took to reach it. Brendan Gregg's FlameGraph scripts turned this into the standard way defenders and operators read a profile.

What perf is actually doing

Profiling here means sampling, and sampling is guesswork done carefully. A factory supervisor cannot watch every worker every second, so a hundred times an hour they walk the floor and note what each person is doing at that exact instant. Do it long enough and the tallies get accurate. If 40 percent of the snapshots catch someone at the packing station, packing is roughly 40 percent of the work. perf profiles a running machine the same way. Many times per second it interrupts every CPU, records the full call stack (the chain of function calls in flight at that instant), and moves on. Thousands of these snapshots add up to a statistical map of where the time goes.

-F 99 sets that rate to 99 samples per second on each CPU. The odd number is deliberate. Sample at a round 100 Hz (hertz, meaning times per second) and you risk marching in lockstep with timers and housekeeping jobs that also fire on round intervals, which biases the tally toward whatever runs on the tick. 99 keeps your sampling out of phase with those rhythms. -a means every CPU, system-wide. -g turns on call-graph recording, so each sample captures the whole stack, from the running function down to the entry point, instead of the top frame alone. The -- sleep 30 on the end is a trick. perf records system-wide for exactly as long as that throwaway sleep runs, which hands you a clean 30-second window.

~/secopslog — bash
$ # perf ships in the linux-tools packages; install it if the command is missing sudo apt install linux-tools-common linux-tools-$(uname -r) perf --version # who is allowed to profile? lower number = more access cat /proc/sys/kernel/perf_event_paranoid
perf version 6.1.140 2

That 2 is the gate. kernel.perf_event_paranoid controls who can profile without being root. A value of 2 or higher blocks unprivileged kernel profiling, and hardened distributions ship it at 3 or higher, which shuts out unprivileged users almost entirely. That is why every command below uses sudo. It also means the ability to run a system-wide profile is a privilege worth guarding, because a profile can reveal a lot about what a box is doing.

Record, fold, render

The recipe is three moves. Record raw samples with perf, fold each stack down to one line, then render those lines into a clickable image. First, grab the tools and record a window of the live system.

~/secopslog — bash
$ git clone https://github.com/brendangregg/FlameGraph.git # confirm the three scripts the pipeline needs are present ls FlameGraph/{stackcollapse-perf,flamegraph,difffolded}.pl
Cloning into 'FlameGraph'... Receiving objects: 100% (1301/1301), 1.92 MiB | 6.4 MiB/s, done. Resolving deltas: 100% (760/760), done. FlameGraph/difffolded.pl FlameGraph/flamegraph.pl FlameGraph/stackcollapse-perf.pl
$ # sample every CPU, 99 Hz, with call stacks, for 30 seconds sudo perf record -F 99 -a -g -- sleep 30
[ perf record: Woken up 8 times to write data ] [ perf record: Captured and wrote 4.12 MB perf.data (18342 samples) ]

Before drawing anything, take the fast text look. perf report reads the perf.data file perf just wrote and ranks functions by how many samples landed on them. This is the flat list a flame graph will later give shape to, and on a compromised box it often ends the investigation on its own.

~/secopslog — bash
$ # text summary first: which functions dominate the CPU? sudo perf report --stdio --sort overhead,comm,dso,symbol | head -14
# Samples: 18K of event 'cpu-clock' # Event count (approx.): 185272984848 # # Overhead Command Shared Object Symbol # ........ ........... .................. .............................. # 41.87% kdevtmpfsi kdevtmpfsi [.] cryptonight_hash 12.30% kdevtmpfsi libc.so.6 [.] __memmove_avx_unaligned 6.44% swapper [kernel.kallsyms] [k] intel_idle 3.11% nginx nginx [.] ngx_http_parse_header_line 2.09% postgres postgres [.] heap_page_prune

There is your answer, and it is a nasty one. kdevtmpfsi is a name chosen to look like a kernel device thread, but it is a real piece of Linux malware from the Kinsing miner family, and cryptonight_hash is the routine that mines Monero (a privacy-focused cryptocurrency). The [.] marks user-space code, [k] marks kernel code. No amount of process-name disguise hides this, because the profile is built from the actual functions the CPU ran, not from whatever the process called itself. Now fold and render, so you can hand a picture to whoever picks up the incident.

~/secopslog — bash
$ sudo perf script | ./FlameGraph/stackcollapse-perf.pl > out.folded ./FlameGraph/flamegraph.pl out.folded > flame.svg xdg-open flame.svg # click a box to zoom in; Ctrl-F to search and highlight

perf script dumps every sample as text, stackcollapse-perf.pl squashes each unique stack into a single semicolon-joined line with a count, and flamegraph.pl draws those lines. The folded file is worth peeking at once, because it demystifies the whole thing. It is only stacks and tallies.

~/secopslog — bash
$ # each line is one unique stack plus the number of samples that hit it grep cryptonight out.folded | head -2
kdevtmpfsi;_start;__libc_start_main;main;mine_loop;cryptonight_hash 7681 kdevtmpfsi;_start;__libc_start_main;main;mine_loop;cryptonight_hash;aesni_encrypt 402

Reading the picture

The SVG (scalable vector graphics, an image format that stays sharp at any zoom and stays clickable in a browser) is one self-contained file you can email to a colleague. Reading it is the real skill, and it comes down to two rules. Width is CPU time. The wider a box, the bigger the share of samples that caught that function running, which is the same as saying it burned more processor time. Height is depth. The stack grows upward, the bottom box is where execution entered, the top box is what was actually running when the sample fired. Left-to-right order carries no meaning at all. Boxes are sorted alphabetically so that identical stacks merge into one wide bar, which is what makes a hot path fat and obvious. Read the horizontal axis as a timeline and you will draw the wrong conclusion.

Reading a flame graph
Width of a box
Share of samples
Wider = more CPU time. The one number that matters.
Height (y-axis)
Stack depth
Bottom = entry point, top = what ran when the sample fired.
Wide flat plateau on top
Hot leaf
A function burning CPU by itself. Optimize this function.
Tall thin tower
Deep call chain
Cost spread across layers. Suspect an expensive abstraction or a call storm.
Left-to-right position
Not time
Sorted only to merge identical stacks. Position means nothing.
Only width and height carry meaning. The left-to-right order is not a timeline.

So two shapes tell most of the story. A wide flat plateau at the top is a leaf function spending CPU on its own work, and that function is where you optimize. A tall narrow tower is a deep chain of calls where the cost is smeared thinly across many layers, which usually points at an expensive abstraction or a function being called far too often. In a browser the picture is live. Click any box to zoom that subtree to full width, and use the search box to light up every place a function appears so you can total a name that is scattered across the graph.

When the flame graph is garbage

Walking a stack is like following a trail of breadcrumbs back to the door. A frame pointer (a CPU register that marks where the current function's stack frame begins) is that breadcrumb. Each frame points at the one below it, so perf can walk from the running function all the way down to the entry point. Optimizing compilers love to reuse that register for real work, so at the common -O2 setting they drop the breadcrumbs (the -fomit-frame-pointer behavior). Without them, perf grabs the top function and then loses the trail, and your flame graph turns into a field of stubby [unknown] and raw hex boxes that tell you nothing. This is the number one reason a flame graph comes out useless, and it is fixable.

perf.data can hold secrets, and needs root
System-wide profiling with -a requires root or a low perf_event_paranoid, so treat the ability to profile as a privileged action. And --call-graph dwarf copies a slice of each thread's live stack memory into perf.data on every sample. Those bytes can include passwords, session tokens, or keys that happened to be sitting on the stack. Handle a perf.data from a production box like a secret. Do not paste it into a ticket, and delete it when you are done.

You have two fixes. Rebuild or reinstall the program with frame pointers kept, using -fno-omit-frame-pointer. This is why Ubuntu 24.04 and recent Fedora now compile the whole distribution with frame pointers on by default, accepting a tiny slowdown in exchange for profiles that actually work. Or leave the binary alone and unwind with DWARF (the debug-info format compilers emit; it records how to rebuild the stack even after the breadcrumbs are gone). Add --call-graph dwarf to the record, and point perf at a debug-symbol server so it can name the functions it finds.

~/secopslog — bash
$ # stacks full of [unknown]? swap frame pointers for DWARF unwinding sudo perf record -F 99 -a --call-graph dwarf -- sleep 30 # let perf pull missing symbols from a debuginfo server on demand export DEBUGINFOD_URLS="https://debuginfod.debian.net" sudo -E perf script | ./FlameGraph/stackcollapse-perf.pl > out.folded
[ perf record: Woken up 214 times to write data ] [ perf record: Captured and wrote 58.7 MB perf.data (17984 samples) ]

Notice the cost. Copying stack memory blew the same 30-second capture from about 4 megabytes to nearly 59, and at high enough sample rates DWARF unwinding starts dropping samples. That is the tradeoff. It works on optimized binaries without a rebuild, but it is heavy. The -E on sudo matters too, because it keeps your DEBUGINFOD_URLS variable when the command runs as root. Without it, the symbol server is never contacted.

Interpreted and JIT (just-in-time, where code is compiled to machine instructions while the program runs) languages need one more piece. perf sees the machine code the runtime generated, but the addresses mean nothing to it unless the runtime writes out a map. Node.js does this with --perf-basic-prof, which drops a /tmp/perf-<PID>.map file (PID is the process identifier) that perf reads to turn addresses into JavaScript function names. Java gives you two routes: run perf with -XX:+PreserveFramePointer and load perf-map-agent to write that same map, or skip perf and use async-profiler, which walks Java's own frames and emits folded output directly. For Python, the practical tool is py-spy, which reads the interpreter's frames from outside the process and can write folded output straight into flamegraph.pl. Without the runtime's help, those frames stay as bare hex. A flame graph is only ever as good as the stacks feeding it, so get the symbols right before you read anything into the shape.

Off-CPU time and before-versus-after

A CPU flame graph only shows work that was running. Think of timing a delivery driver by clocking only the minutes the van is actually moving. The twenty minutes idling at a loading dock never show up, even though that idle stretch is what made the delivery late. A CPU profiler has the same blind spot. It says nothing about a request that spent two seconds parked, waiting on a lock, a disk, or a slow database reply, because a waiting thread is off-CPU and never gets sampled. That waiting is the other half of most latency problems. An off-CPU flame graph fills the gap by drawing time spent blocked instead of time spent running, built from the kernel's scheduler events rather than CPU samples. The modern way to capture it is offcputime-bpfcc from the bcc tools (the BPF Compiler Collection, a set of ready-made programs built on eBPF, the kernel's in-house engine for running small tracing programs safely inside the kernel). It emits folded stacks you feed to the same flamegraph.pl.

The other high-value move is proving a fix worked. Record a folded profile before your change and another after, then draw the difference. difffolded.pl colors what got hotter and what got cooler, so a shrunken hot path is something you can see rather than something you claim.

~/secopslog — bash
$ ./FlameGraph/difffolded.pl before.folded after.folded \ | ./FlameGraph/flamegraph.pl > diff.svg

For a defender, that same before-and-after is your verification loop after you kill a miner or patch a runaway service. Capture 30 seconds, confirm the hot path is gone, and keep the SVG as evidence. The moment a box goes hot for a reason nobody can name, the first move is one perf record -a -g and one folded flame graph. It gives you the function that is burning CPU, along with the process it lives in, and that is usually the whole investigation.

Quick check
01You are looking at a CPU flame graph and one box is noticeably wider than its neighbours. What is that width actually reporting?
Correct — Sample counts are the only currency a flame graph deals in. A function present in a quarter of the snapshots gets a quarter of the width, and that ratio is your estimate of the processor time it burned.
Incorrect — Horizontal position is set by alphabetical sorting so that identical stacks merge into one fat bar. Nothing about left or right encodes when something happened, and treating it as a clock sends you after the wrong function.
Incorrect — Sampling never counts calls. One invocation that holds the CPU for ten seconds draws a wide box, while a million calls that each finish in nanoseconds may draw a sliver you cannot even click.
Incorrect — Allocation totals come from a memory profiler working off a different event source. This picture is assembled from CPU stack snapshots, so nothing in it knows how many bytes anyone requested.
02perf report ranks cryptonight_hash at the top, and the process running it calls itself kdevtmpfsi, a name picked to pass for an ordinary kernel thread. Why does that disguise buy the miner nothing here?
Incorrect — There is no roster of blessed thread names anywhere in perf. It records instruction addresses, resolves them to symbols, and never compares a process name against anything at all.
Incorrect — That tag only tells you which side of the kernel boundary a sample landed on. A miner's hashing routine is ordinary user-space code carrying [.], and a kernel-sounding process name does not conjure a [k] marker.
Correct — Sampling reads the instruction pointer and the stack, not the process table. Whatever the miner calls itself, the hashing routine still has to execute on a CPU, and that is exactly what gets sampled.
Incorrect — The two are unrelated. Even a miner that stripped or renamed every symbol would still leave a fat unexplained box burning cycles under a process you can name and go look at.
03You record a profile of a stock production binary and the picture comes back as a field of stubby [unknown] boxes and raw hex addresses, with barely a named function anywhere. What went wrong, and what fixes it?
Incorrect — Rate decides how many snapshots you gather, which is a question of statistical confidence. A stack that cannot be walked stays unwalked at any frequency, and cranking the rate only gives you a bigger file full of the same hex.
Incorrect — That knob decides whether an unprivileged user is allowed to profile at all. Set high it refuses you the samples outright; it has no mode where it hands you samples with the names quietly removed.
Incorrect — A browser buys you click-to-zoom and the search box, but every label was baked into the file when flamegraph.pl drew it. If the folded stacks arrived nameless, no viewer can invent the missing names.
Correct — perf walks a stack by hopping from one frame pointer down to the next. An optimizing compiler claims that register for real work, so the walk stops at the top frame and everything underneath arrives as bare addresses.

Try this

Work through “Off-CPU time and before-versus-after” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: perf.data can hold secrets, and needs root. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related