skip to content

How does a sampling profiler estimate where a Python program spends its time?

level: middleimportance: must knowfreq 52%

answer

  1. Nobody counts every call
  2. Wake up, look at the stack
  3. Hit counts stand in for time
  4. Leaf frames are self time
  5. Error falls with square root of samples

basics

~20 s

A sampling profiler interrupts the program at a fixed interval, records the current call stack, and counts how often each stack appears. A frame present in 30% of samples held the interpreter for roughly 30% of the profiled period.

solid answer

~50 s

Sampling is statistical rather than exhaustive. Something — a background thread, an operating-system interval timer, or a separate process reading the target's memory — wakes every few milliseconds, walks the call stack of each thread it cares about, and increments a counter for that exact stack. After thousands of samples the counts approximate a time distribution: a frame in 30% of samples was on the stack about 30% of the time. **Total time** counts a frame anywhere in the stack; **self time** counts it only as the leaf. Cost depends on the sample rate, not on how many calls the program makes, so a call-heavy loop is not distorted the way per-call instrumentation distorts it. The price is resolution: one fast call may never be caught, and every percentage carries sampling error that shrinks only with the square root of the sample count.

code

python · 39 lines
python
import collections
import sys
import threading
import time
import traceback

samples = collections.Counter()
stop = threading.Event()
target = threading.get_ident()


def sampler(interval=0.005):
    while not stop.wait(interval):
        frame = sys._current_frames().get(target)
        if frame is not None:
            stack = traceback.extract_stack(frame)
            samples[tuple(f.name for f in stack)] += 1


def inner(n):
    return sum(i * i for i in range(n))


def outer():
    total = 0
    for _ in range(20):
        total += inner(200_000)
    return total


thread = threading.Thread(target=sampler, daemon=True)
thread.start()
outer()
stop.set()
thread.join()

total_samples = sum(samples.values())
for stack, hits in samples.most_common(3):
    print(f"{hits / total_samples:6.1%}  {' -> '.join(stack)}")

go deeper

for a junior

Recall the one-sentence mechanism: the profiler interrupts the program regularly, writes down the call stack, and counts. Percentages in the report are estimates from those counts, not exact measurements.

for a middle

Be ready to explain self versus total time, the counts-to-percentage step, and why cost tracks the sample rate rather than the number of calls. Naming one blind spot, such as very fast functions being missed individually, is expected here.

for a senior

Show that you read profiles critically: judge whether enough samples were collected, distinguish a dispatcher with big total time from a real leaf hot spot, and account for native frames and unsampled threads before you act on a graph.

for a principal

Own the tradeoff between fidelity and safety across a fleet: what resolution the team actually needs to make decisions, when a sampled answer is good enough, and when a question genuinely requires a different instrument in a lab.

### The idea A sampling profiler answers "where is time going?" by polling instead of bookkeeping. Every *N* milliseconds it captures the call stack — the chain of frames from the entry point down to the function currently executing — and adds one to a counter keyed by that whole chain. It never observes a call or a return. Everything it reports is inferred from the shape of the stacks it happened to catch. ### Turning counts into time If the sampler fires 200 times per second for 60 seconds, it holds 12,000 stacks. A frame that appears in 3,600 of them was on the stack in 30% of the observed instants, so the estimate is that it was active for about 30% of the 60 seconds. Two different numbers fall out of the same data: - **Total (inclusive) time** — samples where the frame appears anywhere in the stack. This is the cost of the function *and everything it calls*. - **Self (exclusive) time** — samples where the frame is the leaf, the innermost entry. This is code actually executing in that function's own body. A function with huge total time and near-zero self time is a router, not a bottleneck; the work is deeper down. That distinction is the single most common misreading of a profile. ### Reading it as a flame graph A flame graph is just those stack counts drawn as nested rectangles: the y-axis is stack depth and the width of a box is the fraction of samples whose stack contained that frame. The x-axis is **not** time; siblings are usually merged and sorted by name so identical stacks stack up into one wide block. The thing to hunt for is a wide plateau near the top of the graph — a frame that is wide *and* is the leaf in most of those samples. A tall, narrow tower is deep recursion, not slow code. ### Why the overhead is bounded The cost model is `sample_rate x cost_per_sample`. Cost per sample is a stack walk, roughly proportional to stack depth, and it is completely independent of how many calls per second the program makes. Instrumenting every call has the opposite profile: its cost scales with the call rate, so the workloads it perturbs most are exactly the tight call-heavy ones you were trying to measure. That is why sampling is the option that is safe to run against a live process at all, and why sampled numbers are usually *more* representative of production behaviour even though they are less precise. ### The blind spots Sampling trades precision for safety, and the trade has sharp edges: - **Sub-interval work is invisible per call.** A function that runs for 50 microseconds under a 5-millisecond sampler is rarely caught individually. In aggregate it still shows up proportionally — if it runs often enough. If it runs rarely, it disappears. - **Statistical error.** The uncertainty on a percentage falls with the square root of the sample count. A hot spot seen in 3 of 40 samples is noise; the same 7.5% out of 4,000 samples is a fact. - **Native frames collapse.** Time spent inside a C extension appears as time in the Python frame that called it; the profiler sees no Python frames below that boundary unless it also walks the native stack. - **The sampler can be starved.** An in-process sampler written in Python is itself a thread, so it must acquire the GIL to run. If the main thread is inside a long C call that holds the GIL, samples are delayed and their timestamps bunch up afterwards. A C extension that *releases* the GIL around a long computation does not have this problem. - **Only sampled threads exist.** Whatever threads the sampler does not walk contribute nothing to the picture, and a profile of the main thread alone in a multi-threaded worker can be actively misleading. ### What the primitives look like In CPython the raw material is `sys._current_frames()`, which returns a mapping of thread id to that thread's current frame; `traceback.extract_stack(frame)` turns one into a readable stack. Through Python 3.14 the standard library ships no sampling profiler of its own — `profile` and `cProfile` are deterministic, per-call tools — so production sampling means either a hand-rolled thread like the one above or an external tool. A signal-driven variant uses `signal.setitimer` so that the interrupt is charged against CPU consumed rather than wall-clock time, but in CPython a Python-level signal handler only ever runs on the main thread, which limits what that approach can see. ### How to answer in an interview State the mechanism (periodic stack capture, counts as a proxy for time), name self versus total time, give the overhead model (rate-driven, not call-driven), and finish with one honest limitation — sampling error, or the native-frame blind spot. That sequence shows you have read profiles rather than merely produced them.

  • In a flame graph built from stack samples, what does the width of a frame mean?
    Width is the share of samples whose stack contained that frame — effectively its total time. The x-axis is not a timeline; sibling frames are merged and ordered by name so identical stacks combine into one block. Height is stack depth, not cost. The signal you want is a wide box that is also the leaf in most of its samples, which is self time and therefore real work.
  • How do you tell self time from total time in a sampled profile?
    Count a frame's appearances anywhere in the captured stack for total time, and only its appearances as the innermost frame for self time. A wrapper or dispatcher shows large total and near-zero self time: it is on the stack constantly but never executing. Optimising it does nothing; the self-time leaves underneath it are where the work is.
  • How many samples do you need before you trust a hot spot?
    Enough that the sampling error is small relative to the difference you care about, and the error shrinks only as the square root of the count. A frame seen in 3 of 40 samples could plausibly be anywhere from noise to significant; the same proportion out of several thousand samples is solid. In practice: profile for long enough to collect thousands of samples of the code path in question, not a burst of a few dozen.

It is exit polling rather than counting every ballot: ask a few thousand random voters and you get the shape of the result within a couple of points, at a tiny fraction of the cost of a full count.

saying these in an interview costs you the question

  • Says a sampling profiler counts every function call
  • Reads a flame graph's x-axis as time order
  • Trusts a hot spot seen in a handful of samples
  • Optimises a frame with high total but no self time
  • Thinks a taller stack means slower code
  • Assumes overhead grows with the program's call rate

context