Engineer's toolkit
Flame graphs and CPU profiling
How sampling profilers find where CPU time goes, how to read a flame graph, and how allocation profiles reveal GC pressure.
A profiler answers one question: where does the time go? A sampling profiler pauses the program at a fixed rate, records the current call stack, and repeats. A function that appears in 30% of samples is using about 30% of the CPU. That is enough to find the expensive part of a service without guessing.
The big idea
There are two families of profiler.
- Instrumentation profilers add timing code at every function call. They count calls exactly, but the overhead can be 2 times or more. That overhead makes tiny, frequently called functions look far more expensive than they are. Python's
cProfileis the familiar example. - Sampling profilers interrupt the program at a fixed rate and record the stack. Overhead is usually a few percent, so you can run them in production.
Concept
Sample: one snapshot of the call stack taken at a set rate. Go's CPU profiler takes 100 samples per second by default. Thirty seconds at 100 Hz on 8 busy cores gives about 24,000 samples, enough to find anything above 1% of CPU.
Walking through a flame graph
A flame graph turns thousands of stacks into one picture. Each box is a function. Read it like this.
- The y-axis is stack depth. The bottom box is the root, such as
main. Each box sits on top of the function that called it. - The x-axis is not time. Boxes are sorted alphabetically so identical stacks merge. A box's width is the share of samples that included that function.
- Wide boxes are where time goes. A wide box with nothing on top of it is a plateau. The CPU spent its time in that function's own code.
Follow the widest path upward. get_orders handler is 94% wide, but most of that width sits in its children. The widest child is serialize_response at 68%. Inside it, dataclasses.asdict takes 41%, and almost all of that is copy.deepcopy at 38%. That box has nothing on top of it, so it is the plateau to attack.
Notice what is missing: database time shows as only 19% here. A CPU profile records time spent running on the CPU. It does not record time spent waiting for the database to answer. That gap is covered in off-CPU time.
Taking a profile
Each runtime has a tool. Here are the common ones.
| Runtime | Tool | Example |
|---|---|---|
| Go | pprof (built in) | go tool pprof -http=:8080 "svc:6060/debug/pprof/profile?seconds=30" |
| Python | py-spy | py-spy record -o cpu.svg --pid 4242 attaches without a restart |
| JVM | async-profiler | CPU, allocation and lock modes |
| Any native code, Linux | perf plus FlameGraph scripts | perf record -F 99 -g -p <pid> -- sleep 30 |
perf runs at 99 Hz rather than 100 Hz so its samples do not line up with periodic work in the program.
For many hosts at once, continuous profilers such as Pyroscope, Parca and Google Cloud Profiler sample all the time at a low rate.
Allocation profiles: the memory side
Memory has two profiles, and they answer different questions.
- Retained memory is what is alive right now. Take two heap snapshots a few minutes apart and diff them. Whatever keeps growing is the leak.
- Allocation rate is how many bytes are allocated each second, even if they die at once. A high rate means the garbage collector runs often. In a service that allocates 2 GB per second, the collector can take CPU that your code needs. See memory management for how collectors work.
Common fixes: reuse buffers, preallocate slices and maps to their final size, stream large responses, and avoid accidental deep copies like the asdict above.
Common mistakes
- Reading the x-axis as time. It is only the share of samples.
- Reading a CPU graph for a slow request that is mostly waiting. The graph will look healthy.
- Profiling a laptop with tiny data. Profile a realistic load on production-like hardware.
- Trusting old JVM profilers that sample only at safepoints. Safepoint bias can move time onto the wrong method. async-profiler avoids this.
Check yourself
- A function takes 30% of samples. About how much CPU is it using? Roughly 30% of the CPU, since samples are taken at a fixed rate.
- A flame graph shows a wide box with nothing on top. What does that mean? The CPU spent its time in that function's own code. It is a plateau to optimize.
Key takeaways
- Sampling profilers are cheap enough for production and show where CPU time goes.
- In a flame graph, width is the share of samples and height is stack depth. The x-axis is not time.
- A wide plateau is the place to look first.
- Allocation rate drives garbage collector cost, so profile it too.
Further learning
Brendan Gregg invented flame graphs in 2011. His page covers how to read them and the variants for CPU, off-CPU and memory.
Brendan Gregg: Flame GraphsHomepage for Flame Graphs: a visualization for stack traces.brendangregg.com py-spy: sampling profiler for PythonSampling profiler for Python programs. Contribute to benfred/py-spy development by creating an account on GitHub.GitHubNext, read off-CPU time to find the time that a CPU profile cannot see.
Related
Performance and profiling
How to find where time goes before you optimize. Percentiles, two predictive laws, sampling profilers, off-CPU waiting and honest benchmarks, with a worked investigation of a slow endpoint. Start here for the performance toolkit.
Off-CPU time
Why a service at 10% CPU can still have slow requests, how to measure the time spent waiting, and which tools show it.
Memory management and garbage collection
How languages decide where values live and when memory is given back, the trade-offs of manual, ownership, reference-counted and tracing approaches, and how to find leaks and size heaps safely.