Profiling PHP Applications: Flame Graphs, Sampling, and Call Data
Learn the profiling workflow for PHP performance work: choosing between sampling and instrumenting profilers, generating and reading flame graphs, and distinguishing wall-clock time from CPU time when interpreting results.
The Debugging with Xdebug lesson covered Xdebug primarily as a step debugger — setting breakpoints, stepping through execution, and inspecting variables to understand why code produces a wrong result. Profiling is a different activity with a different goal: not 'why is this value wrong' but 'where does time and memory actually go while this code runs correctly'. This lesson goes deep on the profiling side specifically — the tools that measure execution rather than pause it, how to read the flame graphs they produce, and the difference between wall-clock time, CPU time, and call counts, all of which are essential vocabulary for any serious PHP performance investigation.
Two families of profiler: instrumenting vs sampling
Xdebug's profiler (when enabled in cachegrind mode) is an instrumenting profiler: it hooks into every function call and return, recording exact timing and call counts for every single function invocation. This gives extremely precise, complete data — but the instrumentation itself adds substantial overhead, often slowing execution down several-fold. That overhead makes Xdebug's profiler excellent for a targeted, local investigation of a specific slow script, but unsuitable for running continuously against production traffic, where the overhead would itself distort results and degrade real users' response times.
Sampling profilers, such as Excimer (a PECL extension built for exactly this use case) or the sampling mode some APM agents use, take a fundamentally different approach: instead of recording every function call, they interrupt the running process at a fixed interval (say, every 10ms) and record just the current call stack at that instant. Over thousands of samples, a statistical picture emerges of where time is spent, with overhead low enough to run safely in production. The tradeoff is precision — a function that runs for 2ms between samples might never get caught — but for finding the dominant costs in real traffic, sampling is usually the more practical and more honest tool, since it measures what production actually experiences rather than an artificially slowed-down version of it.
• Use an instrumenting profiler (Xdebug's cachegrind mode) when: you have a specific slow script reproduced locally or in staging, and want exact call counts and per-function timing to explain it precisely. • Use a sampling profiler (Excimer or similar) when: you need to profile real production traffic continuously, or the workload is too large/variable to reproduce locally with acceptable overhead. • Neither replaces the other — many teams sample continuously in production to spot regressions and hot paths, then reach for Xdebug's instrumenting profiler locally once a specific function or request pattern is identified as worth a closer look.
Generating and reading a flame graph
A flame graph is the standard way to visualize profiling output, whether it came from sampling or instrumentation. Each box represents a function; the x-axis width represents the proportion of total time spent in that function (across all samples or all calls), and the y-axis represents the call stack depth — a box's children, stacked above it, are the functions it called. Despite the name, the x-axis is not time-ordered left to right; it's typically sorted alphabetically or by size, so reading a flame graph is about width (how much total time), not left-to-right position (unless the tool specifically produces a time-ordered variant).
The practical reading technique: look for the widest boxes near the top of the stack (furthest from main()/the request entry point) — a wide box high up in the stack means a huge share of total time is spent inside that specific function's own body, not in something it calls. A wide box low in the stack (near the bottom) just means its descendants are expensive; you need to look at what's stacked above it to find the actual culprit. Tools like Xdebug's cachegrind output (viewed in KCachegrind/QCachegrind or webgrind) and Excimer's output (often rendered with a flamegraph generator like Brendan Gregg's flamegraph.pl or speedscope) both follow this same visual convention.
Typical workflow: generate the profile (cachegrind file from Xdebug, or a collapsed-stack file from Excimer), render it as a flame graph, then scan for the widest top-level boxes — those are your highest-leverage optimization targets, exactly by the same 'fix the biggest piece first' logic covered in the Performance Fundamentals lesson.
Wall-clock time vs CPU time vs call counts
A profiler that reports only one number per function is easy to misread. The three numbers that matter, and why they diverge, are worth knowing cold:
• Wall-clock (wall) time: the actual elapsed real-world time a function's execution took, including any time spent waiting — for a database, a socket, a file lock, another process. This is what users actually experience. • CPU time: the time the CPU was actively executing instructions for that function, excluding time spent blocked/waiting on I/O. A function that calls curl_exec() and waits 200ms for a slow API might show 200ms of wall time but only a fraction of a millisecond of CPU time — the CPU was idle, not busy, for almost the entire duration. • Call count: how many times a function was invoked. A function with a tiny per-call cost but called 50,000 times in a request (a classic N+1 pattern, or a poorly memoized helper called inside a loop) can dominate total time even though no single call looks alarming in isolation. • The diagnostic power comes from comparing them: high wall time + low CPU time on a function almost always points to I/O or network waiting (confirming the Performance Fundamentals lesson's point that most PHP request time is I/O-bound, not CPU-bound). High CPU time on a function points to genuine in-process computational cost, where OPcache/JIT-level optimizations are relevant. A high call count with low total time per call, but a large aggregate, points to a loop or N+1 pattern worth batching or caching rather than a slow function to rewrite.
Sampling a request with Excimer
EXCIMER_REAL samples wall-clock time (catches I/O waits); EXCIMER_CPU samples only while the CPU is actually busy. Profiling with both, and comparing, is exactly how you distinguish 'waiting on the network' from 'genuinely CPU-heavy'. formatCollapsed() produces the standard collapsed-stack text format that flamegraph.pl or speedscope can render directly into an interactive flame graph.
<?php
// Runs safely under real traffic thanks to low sampling overhead.
$profiler = new ExcimerProfiler();
$profiler->setPeriod(0.01); // sample every 10ms of wall-clock time
$profiler->setEventType(EXCIMER_REAL); // EXCIMER_REAL = wall time, EXCIMER_CPU = CPU time
$profiler->start();
// ... normal application code handles the request here ...
$profiler->stop();
// Collapsed-stack format, ready to feed into a flame graph generator.
file_put_contents(
'/var/log/php-profiles/' . uniqid('req_', true) . '.collapsed',
$profiler->getLog()->formatCollapsed()
);Code Examples
Enabling xdebug.mode=profile writes a cachegrind-format file for every request. Because instrumentation overhead is high, this configuration is meant for a local or staging reproduction of a specific slow request — not a general production setting — which is why the file also documents 'local/staging use only'. Expected result: # Produces: /tmp/xdebug-profiles/cachegrind.out.18422 # Open with: kcachegrind cachegrind.out.18422 (Linux) # or: qcachegrind cachegrind.out.18422 (cross-platform) # or convert with webgrind for a browser-based view xdebug.mode = profile
Running both a wall-time and a CPU-time profiler over the same request lets you separate 'time spent waiting' from 'time spent computing'. Here, fetchInventory() will accumulate many wall-clock samples (it's blocked on the network) but almost no CPU samples, while computeDiscount() will show up proportionally more in the CPU profile — exactly the signature that tells you the network call, not the discount math, is the thing worth optimizing. Expected result: Wall samples logged: 21 CPU samples logged: 2 With a 10ms sampling period, 21 wall-time samples versus 2 CPU-time samples over the same call roughly indicates the call spent about 10x longer blocked/waiting than it did actively computing — consistent with fetchInventory()'s network call dominating the wall-clock duration while computeDiscount()'s loop is nearly free by comparison.
; php.ini (or a per-request override), local/staging use only
xdebug.mode = profile
xdebug.output_dir = /tmp/xdebug-profiles
xdebug.profiler_output_name = cachegrind.out.%p<?php
function fetchInventory(string $sku): array
{
// Simulates a slow external inventory service.
$ch = curl_init("https://inventory.example.test/v1/sku/{$sku}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
function computeDiscount(array $items): float
{
$total = 0.0;
foreach ($items as $item) {
$total += $item['price'] * 0.9;
}
return $total;
}
$wall = new ExcimerProfiler();
$wall->setPeriod(0.01);
$wall->setEventType(EXCIMER_REAL);
$cpu = new ExcimerProfiler();
$cpu->setPeriod(0.01);
$cpu->setEventType(EXCIMER_CPU);
$wall->start();
$cpu->start();
$items = fetchInventory('SKU-1001');
$discountTotal = computeDiscount($items);
$wall->stop();
$cpu->stop();
echo "Wall samples logged: " . count(iterator_to_array($wall->getLog())) . "\n";
echo "CPU samples logged: " . count(iterator_to_array($cpu->getLog())) . "\n";Cross-reference and production workflow
In practice, teams combine both tools from this course: a sampling profiler like Excimer runs continuously (or is triggered on a percentage of requests) in production with negligible overhead, feeding flame graphs into dashboards that highlight regressions over time. When a specific request or function stands out, engineers reproduce it locally or in staging and switch to Xdebug's instrumenting profiler (in cachegrind mode) — or its step-debugging mode, as covered in the Debugging with Xdebug lesson — to get exact call counts and pin down the precise line responsible. Sampling finds where to look; instrumentation and debugging explain exactly why once you're looking there.
// Sees a function with high "time" in a profiler and assumes it's CPU-bound, // then spends a day trying to micro-optimize its loop body. function fetchInventory(string $sku): array { /* ...calls a slow external API... */ }
// Check CPU time, not just wall time, before assuming a function is compute-heavy. // If CPU time is near zero while wall time is high, the function is waiting on I/O — // the fix is caching, concurrency, or a timeout/circuit breaker, not a faster loop.
A profiler that reports only wall-clock time makes every I/O-bound function look identical to a genuinely CPU-bound one. Without comparing against CPU time (or knowing the function makes a network call), it's easy to spend optimization effort rewriting code that was never the actual bottleneck — the wait, not the computation, was the cost.
Why are sampling profilers like Excimer generally preferred over instrumenting profilers (like Xdebug's cachegrind mode) for continuous production use? A. Sampling profilers produce more precise per-call timing than instrumenting profilers B. Sampling profilers have much lower overhead because they record periodic call-stack snapshots instead of hooking every function call and return C. Instrumenting profilers cannot be used with PHP-FPM D. Sampling profilers do not require any code changes to interpret
B. Sampling profilers have much lower overhead because they record periodic call-stack snapshots instead of hooking every function call and return — Sampling profilers interrupt execution at a fixed interval and record just the current call stack, which is far cheaper than instrumenting every function call and return, making them safe to run continuously against production traffic — at the cost of some statistical imprecision.
A function shows high wall-clock time but very low CPU time in a profile. What does this most likely indicate? A. The function is heavily CPU-bound and needs algorithmic optimization B. The profiler is misconfigured C. The function spends most of its time waiting on something external, such as a database or network call, rather than actively computing D. The function was never actually called during the request
C. The function spends most of its time waiting on something external, such as a database or network call, rather than actively computing — High wall time with low CPU time means the function was mostly idle/blocked rather than actively using the CPU — the classic signature of I/O or network waiting, not computational cost.
In a flame graph, what does the width of a box represent? A. The order in which the function was called relative to other functions B. The proportion of total time attributed to that function (across all samples or calls) C. The number of lines of code in that function D. The memory address of the function in the compiled opcode array
B. The proportion of total time attributed to that function (across all samples or calls) — Box width in a flame graph encodes the proportion of total time (or samples) spent in that function; stack depth (the y-axis) shows the call hierarchy, not elapsed left-to-right time.
Take a request in your application that feels slow. Run it once with a sampling profiler (or Xdebug in profile mode) using EXCIMER_REAL / wall time, and once using EXCIMER_CPU / CPU time if available. Render both as flame graphs. Find any function that is wide in the wall-time graph but narrow (or absent) in the CPU-time graph — that function is a candidate for caching, batching, or concurrency rather than algorithmic optimization.
Functions that call out to a database, cache, filesystem, or external API will typically show large wall-time width with minimal CPU-time width; pure computation functions will show roughly proportional width in both.
If you don't have Excimer available, Xdebug's cachegrind output combined with an awareness of which functions perform network/database calls can approximate the same diagnosis manually.