skip to content

Profiling Techniques

Timing code with hrtime, choosing a sampling or an instrumenting profiler, and reading call graphs and flame graphs. Interviewers ask how you found the slow path instead of guessing it.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

5

After a release, a PHP product page is 800 ms slower in production; how do you find the slow path instead of guessing at it?

level: seniorimportance: must knowfreq 45%

answer

  1. confirm, scope, then measure
  2. wall versus CPU split first
  3. hrtime spans per phase
  4. sample a fraction of production
  5. diff the old and new profile

basics

~20 s

Confirm and scope the regression, split wall time from CPU time, narrow it with hrtime spans around each phase, then profile production traffic with a low-overhead sampling profiler on a small fraction of requests and compare against the previous release.

solid answer

~40 s

First **confirm and scope**: which percentile moved, on which route, for all products or only some, starting exactly with the deploy. Then **split** wall from CPU time with per-request `hrtime(true)` and `getrusage()` deltas: if CPU barely changed, the new time is waiting on I/O. Next, **narrow** with `hrtime()` spans around the phases (queries, outbound HTTP, rendering), logged with the request id, to find the phase that grew. Then **profile**: a sampling profiler on a small fraction of production requests, or on requests carrying a trigger, shows the stack at real data volumes. Where you need exact call counts, reproduce with production-like data under an instrumenting profiler. **Diff** old against new: which function's inclusive time grew, and did a call count jump from 3 to 240? Fix, then verify with the same measurement.

go deeper

for a junior

Know that slowness is measured, not guessed: time the request, then time its parts. Mention hrtime(true) for durations.

for a middle

Describe the wall-versus-CPU split with getrusage() deltas and hrtime spans around queries, HTTP calls and rendering. Explain why production-sized data matters.

for a senior

Lead the investigation in order: scope, split, spans, sampled production profiles, and a diff against the previous release with call counts. Explain why instrumentation stays out of full production traffic.

for a principal

Turn the investigation into prevention: per-route latency budgets, spans and CPU time logged by default, and profile comparisons in the release process, so the next regression is caught before users notice.

## Principle: measure, narrow, then profile "The page got slower after the release" is a symptom. Guessing from the diff ("it must be the new serializer") wastes days when the release contained fifty changes. The method below narrows the search step by step, and each step is cheap before the next, more expensive one. ## Step 1: confirm and scope Before touching a profiler, answer: - **Which metric moved?** p50 and p95 tell different stories. If the median is unchanged but p95 grew by 800 ms, a subset of requests got slow, often a data-dependent path. - **Where?** One route, or all routes? If every page is slower, suspect something shared (bootstrap, configuration, a middleware, a cache that stopped hitting). If only the product page is slower, suspect code on that path. - **Which products?** Products with many reviews or variants often expose a loop whose cost grows with the data. - **When?** A change that starts precisely at the deploy and stays points at the release; one that fades after a few minutes points at a transient warm-up instead. ## Step 2: split wall time and CPU time Log per-request wall time from `hrtime(true)` deltas and CPU time from `getrusage()` deltas (user plus system, snapshot at start and end, since an FPM worker's counters span many requests). | Before | After | Reading | |---|---|---| | 200 ms wall / 120 ms CPU | 1,000 ms wall / 130 ms CPU | the new time is **waiting**: queries, HTTP calls, locks | | 200 ms wall / 120 ms CPU | 1,000 ms wall / 900 ms CPU | the new time is **PHP computing** | This single split decides whether you look at I/O or at code. ## Step 3: narrow with spans Wrap the main phases in `hrtime(true)` spans and log them with the route and request id: loading the product, loading reviews, calling the pricing API, rendering the template. Aggregated over a few thousand requests, one phase will stand out. This costs almost nothing and runs safely in production. ## Step 4: profile in production, carefully Spans tell you which phase; a profiler tells you which function inside it. 1. **Sampling in production.** Enable a sampling profiler on a small fraction of requests (for example one in a hundred), or only on requests that carry a trigger such as a header your team controls. Overhead stays bounded, and the profile reflects real data, real caches and real concurrency. 2. **Wall-clock mode** if Step 2 showed waiting, **CPU mode** if it showed computation. 3. **Instrumentation in reproduction.** When you need exact call counts, reproduce the slow request in staging with production-sized data and run an instrumenting profiler there. Never switch an instrumenting profiler on for all production traffic: its per-call overhead would slow every request and skew the results. ## Step 5: compare against the previous release A single profile shows where time goes; a **comparison** shows what changed. Profile the same route on the old and new release (or old and new data) and diff: - **inclusive time** per function: which subtree grew by roughly 800 ms? - **call counts**: did `PDOStatement::execute()` go from 3 calls to 240, or a remote client's call from 1 to 50? - **new frames**: functions that did not appear before at all. Typical findings behind a sudden 800 ms: a lazy-loaded relation accessed inside a loop, a cache lookup whose key now changes on every request, a new outbound call made synchronously, or a regular expression or sort applied to a much larger input. ## Step 6: fix and verify with the same measurement After the fix, check the same percentiles, the same wall/CPU split and the same spans that exposed the problem. A fix that looks right in a local benchmark but does not move production p95 has not fixed the regression. ## Traps - Profiling locally with ten products when production has thousands of reviews per product. - Reading an instrumented profile's absolute times as production latency. - Declaring victory from one fast request. - Blaming the most recently changed file without evidence. - Profiling right after a deploy, when first requests pay one-time warm-up costs that later requests do not.

  • Why not simply enable an instrumenting profiler on every production request until the slow path shows up?
    Its per-call overhead would slow every request, possibly more than the regression itself, and it inflates call-heavy code, so the profile would misrepresent where production time goes. Sampling a fraction of requests, or triggering profiling on chosen requests, keeps overhead bounded and results representative. Use instrumentation in a reproduction environment when you need exact counts.
  • The slowdown appears only for some products. How does that change the investigation?
    It points at a data-dependent path: code whose cost grows with the number of reviews, variants or images. Profile a slow product and a fast one and compare call counts. A function whose calls scale with the product's data, such as one query per review, is usually the cause, and it would not show up at all when testing with small fixtures.
  • Profiling shows the new time inside curl_exec() calls to an internal pricing service. Is the investigation over?
    It has moved rather than finished. The PHP side now needs call counts and timing: is the release calling the service more often, sequentially instead of once, or with larger payloads? If the count is unchanged, the latency is the service's own and the investigation continues on that service's side. Either way, the evidence now names a specific call.

saying these in an interview costs you the question

  • Starts by reading the release diff and guessing which change caused it.
  • Turns on an instrumenting profiler for all production traffic.
  • Reproduces the slowdown locally with tiny fixture data and finds nothing.
  • Looks only at average latency and misses a slow subset of requests.
  • Declares the regression fixed after one fast request in staging.
open as a page

In PHP, why should you time a block of code with hrtime(true) instead of microtime(true), and how do you turn the result into milliseconds?

level: juniorimportance: should knowfreq 38%

basics

~20 s

hrtime(true) reads a monotonic clock in nanoseconds, so the difference of two calls is a true elapsed time; microtime(true) reads the adjustable wall clock as float seconds. Subtract two hrtime(true) values and divide by 1e6 for milliseconds.

open as a page

In a PHP profiler's call graph, what is the difference between inclusive and exclusive time, and which one tells you what to optimize?

level: middleimportance: should knowfreq 35%

basics

~20 s

Inclusive time includes everything a function called; exclusive (self) time is its own body only. Follow inclusive time down to find the costly branch, then exclusive time and call counts to find the code to change.

open as a page

When profiling PHP code, how does an instrumenting profiler differ from a sampling profiler, and why can instrumentation mislead you about where time goes?

level: middleimportance: should knowfreq 32%

basics

~20 s

An instrumenting profiler hooks every PHP function entry and exit, giving exact call counts but adding cost per call; a sampling profiler records the current call stack at a fixed interval, with low, predictable overhead but only statistical results.

open as a page

In PHP, a request takes 900 ms of wall time but its getrusage() CPU time is only 60 ms; what does that gap mean?

level: middleimportance: should knowfreq 30%

basics

~20 s

Wall time is elapsed real time; CPU time is what the PHP process spent executing, from getrusage() as user plus system time. A 900 ms versus 60 ms gap means the request mostly waited on I/O.

open as a page