TL;DR — Flame graphs turn raw stack‑sampling data into an instantly readable heat map of CPU time. By wiring them into CI/CD or observability pipelines you can spot hidden hot paths, understand metadata like in‑kernel wait states, and act on concrete performance bugs before they reach users.
Performance engineers spend countless hours chasing “slow” requests, only to discover that the root cause lives in a few microseconds of kernel‑mode activity or an obscure library call. Flame graphs compress that noise into a visual hierarchy that highlights where the program actually spends its time. This post walks through the theory, the tooling, and the production‑ready architecture you need to turn a stack trace dump into actionable insight.
What Is a Flame Graph?
A flame graph is a stacked, horizontally‑oriented histogram where each box represents a function (or symbol) and its width is proportional to the amount of sampled time spent in that stack frame. The “flames” rise from the bottom (root functions) to the top (leaf functions), making hot paths visually pop like a fire.
Origins and Theory
The technique was popularized by Brendan Gregg in 2010 as a way to visualize perf‑data on Linux as described in his original article. The key insight is sampling: instead of instrumenting every function entry/exit (which adds overhead), a profiler periodically records the current call stack. Over many samples, the frequency of a stack element approximates its share of CPU time.
Mathematically, if you take N samples and a particular function appears in k samples, the estimated CPU usage for that function is k/N. Because the stacks are aggregated, overlapping frames automatically sum their contributions, revealing both inclusive and exclusive costs.
Generating Flame Graphs in Production
While a local perf record && perf script workflow is fine for debugging a dev box, production environments demand repeatable, low‑overhead pipelines.
Sampling vs. Instrumentation
| Approach | Overhead | Granularity | Typical Use‑Case |
|---|---|---|---|
| Statistical sampling (perf, eBPF, OpenTelemetry) | 1–5 % CPU | Millisecond‑scale | Long‑running services, CI pipelines |
| Instrumentation (DTrace, SystemTap, custom probes) | 10–30 % CPU | Function‑level | Short‑lived jobs, debug builds |
| Hybrid (eBPF‑based perf events + occasional probes) | 2–7 % CPU | Adjustable | Production microservices with occasional deep dives |
In practice, most SaaS back‑ends use eBPF‑based collectors because they can attach to any binary without recompilation and keep overhead below 5 %.
A Minimal Bash Pipeline
#!/usr/bin/env bash
# Record 10 seconds of CPU stacks for PID $1 and generate a flame graph.
PID=$1
DURATION=${2:-10}
OUTDIR=${3:-flamegraph-output}
mkdir -p "$OUTDIR"
# 1️⃣ Collect raw perf data (requires root or CAP_PERFMON)
perf record -F 99 -p "$PID" -g --output "$OUTDIR/perf.data" -- sleep "$DURATION"
# 2️⃣ Convert to folded stacks
perf script -i "$OUTDIR/perf.data" | \
awk '{printf("%s;", $5)} END {print ""}' > "$OUTDIR/perf.folded"
# 3️⃣ Render SVG (requires Brendan Gregg's flamegraph.pl)
./flamegraph.pl "$OUTDIR/perf.folded" > "$OUTDIR/flamegraph.svg"
The script is deliberately terse: it uses perf’s -g flag to capture call graphs, folds the output, and hands it to the classic flamegraph.pl. In production you would replace the Bash wrapper with a side‑car container that streams the folded output to a storage bucket for later rendering.
Using eBPF with OpenTelemetry Collector
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
hostmetrics:
collection_interval: 10s
scrapers:
cpu:
process:
metrics:
- cpu.time
- process.cpu.time
exporters:
otlphttp:
endpoint: https://api.datadoghq.com/api/v2/trace
headers:
DD-API-KEY: "${DD_API_KEY}"
processors:
batch:
service:
pipelines:
metrics:
receivers: [hostmetrics]
processors: [batch]
exporters: [otlphttp]
The OpenTelemetry Collector can be extended with the ebpf-profiler processor (see the Datadog documentation) to emit folded stacks as a custom metric, which downstream can be turned into a flame graph in the UI. This approach eliminates the need for a separate perf binary on the host.
Architecture: Integrating Flame Graphs with Existing Observability Stack
A production‑grade architecture treats flame graphs as first‑class artifacts alongside logs, traces, and metrics.
+-------------------+ +--------------------+ +-------------------+
| Application Pods | ---> | Side‑car eBPF Agent| ---> | Collector (OTel) |
+-------------------+ +--------------------+ +-------------------+
| |
v v
+----------------+ +-----------------+
| Folded Stacks | ---> | Storage (S3) |
+----------------+ +-----------------+
|
v
+-----------------+
| Flamegraph Gen |
| (Lambda/Job) |
+-----------------+
|
v
+-----------------+
| Dashboard (Grafana/Datadog) |
+-----------------+
- Side‑car eBPF Agent – attaches to the process namespace, samples at 99 Hz, writes folded stacks to a local buffer.
- Collector – ships the buffer to a central OpenTelemetry collector that enriches the payload with service name, version, and request IDs.
- Storage – folded files land in object storage (e.g., Amazon S3) with a predictable key pattern:
service/<date>/<instance>.folded. - Flamegraph Generator – a lightweight Lambda (or Kubernetes Job) watches the bucket, runs
flamegraph.pl, and stores the resulting SVG next to the folded file. - Dashboard – Grafana’s flamegraph panel or Datadog’s Profile view pulls the SVG on demand, overlaying request IDs for correlation with traces.
The separation of concerns allows you to scale each component independently: sampling stays low‑latency, while rendering can be batched overnight for cost‑savings.
Patterns in Production: Common Bottleneck Types
Real‑world services reveal recurring hot‑path patterns. Recognizing them speeds up triage.
1. Lock Contention Spikes
A narrow flame at the bottom showing pthread_mutex_lock expanding into a wide region of the same function often indicates a lock that serializes many goroutines or threads. Counter‑measure: switch to a sharded lock or use lock‑free data structures.
2. GC / Runtime Overhead
In Go services you’ll see runtime.mcall or runtime.gopark dominating the upper layers. This signals excessive goroutine parking or GC work. Profiling with go tool pprof -alloc_space alongside flame graphs can confirm allocation hot spots.
3. Syscall Wait States
Linux kernels expose __schedule and do_syscall_64 frames. A flame that spends a large fraction in read or write without progressing often points to I/O saturation, network back‑pressure, or slow external APIs. Pair the flame with netstat metrics to verify.
4. JIT / Interpreter Overheads
For Java or Node.js, you may see Interpreter or JITCompiler frames. A sudden increase after a deployment can indicate sub‑optimal bytecode generation or de‑optimization caused by type churn.
5. Hidden Library Calls
Third‑party SDKs sometimes hide expensive work behind callbacks. A flame that shows aws_sdk::client::request widening into serde_json::to_string hints at costly JSON serialization. Refactor to protobuf or batch payloads.
Interpreting Hidden Execution Metadata
Beyond the obvious function names, flame graphs carry metadata that can be extracted programmatically.
In‑Kernel Wait States
When using perf with the -W flag, you get extra columns like WCHAN. Folding those into the stack yields entries such as __schedule (IO) or __schedule (CPU). By grouping on the suffix you can generate wait‑state flame graphs that differentiate I/O wait from CPU spin.
perf record -e cycles:u -g -W -p $PID -- sleep 5
perf script -i perf.data | \
sed -E 's/(\w+)\s+\(([^)]+)\)/\1_\2/g' | \
./stackcollapse-perf.pl > perf.folded
The sed command rewrites __schedule (IO) into __schedule_IO, allowing the flamegraph to treat each wait type as a distinct leaf.
Request‑ID Correlation
If your service propagates a X-Request-ID header, you can inject it into the eBPF map as a per‑CPU key. The side‑car then emits folded stacks prefixed with the request ID:
req-12345;http_handler;process_data;db_query
req-12345;http_handler;process_data;cache_lookup
req-67890;http_handler;auth_check;jwt_verify
Rendering per‑request flames makes it trivial to spot outlier latency paths without digging through traces.
Memory Allocation Tags
In languages that support allocation tagging (e.g., Rust’s jemalloc with mallctl), you can augment the stack with a malloc_tag field. The resulting flame graph surfaces “memory‑pressure” hot spots, guiding you to cache‑friendly data structures.
Key Takeaways
- Flame graphs turn raw stack samples into a heat‑map of CPU time, making hot paths instantly visible.
- Statistical sampling (eBPF, perf, OpenTelemetry) provides production‑grade overhead (<5 %) while preserving sufficient resolution for most bottlenecks.
- Integrate flame graph generation into your observability pipeline: side‑car agents → collector → storage → render → dashboard.
- Common production patterns—lock contention, GC pauses, syscall wait states, JIT churn, and hidden library costs—appear as characteristic flame shapes.
- Leverage hidden metadata (wait states, request IDs, allocation tags) to create richer, context‑aware visualizations that bridge the gap between metrics, logs, and traces.
Further Reading
- Flame Graphs – Brendan Gregg – The original tutorial and toolset.
- Linux perf Wiki – Comprehensive reference for perf commands and options.
- Datadog Continuous Profiling – Production‑grade profiling SaaS with built‑in flame graph support.
- OpenTelemetry Collector Configuration – How to ship custom metrics and folded stacks.
- Pyroscope – Open‑Source Continuous Profiling – An alternative ecosystem for storing and querying flame graphs.