skip to content

A cProfile run on an invoice-PDF renderer blames a tiny helper called millions of times; how far do you trust that number?

level: seniorimportance: should knowfreq 38%

answer

  1. The tool charges per event, not per second
  2. Error scales with call count
  3. Compare the run with and without it
  4. Move the measured boundary outward
  5. Ranking survives; absolute seconds do not

basics

~20 s

Trust the ranking, not the seconds. cProfile pays a fixed cost per call and return event, so a function called millions of times absorbs enormous instrumentation cost. Compare the profiled run against an unprofiled one before acting.

solid answer

~50 s

`cProfile` is deterministic: it fires on every call and return, so its error scales with **call count** while the work you care about scales with the input. A helper invoked millions of times therefore attracts overhead charged to its own row, and can look hot mainly because it is measured often. The check is cheap — run the same work without the profiler and compare totals. If the profiled run takes several times longer, the per-call seconds are fiction, though the ranking usually survives because overhead tracks call count and a huge call count is itself a real finding. Practical responses: profile a coarser unit so far fewer events are recorded, read `ncalls` next to the times, compare before-and-after profiles captured identically so overhead cancels, and always confirm the win with an unprofiled measurement. When per-call overhead dominates, a sampling profiler is the better instrument.

code

python · 18 lines
python
import cProfile, time

def encode_glyph(ch):
    return ch.encode("latin-1", "replace")

def render(n):
    return sum(len(encode_glyph(c)) for c in "invoice" * n)

t0 = time.perf_counter()
render(200000)
plain = time.perf_counter() - t0

prof = cProfile.Profile()
t0 = time.perf_counter()
prof.runcall(render, 200000)
profiled = time.perf_counter() - t0

print(f"plain {plain:.3f}s  profiled {profiled:.3f}s  ratio {profiled / plain:.1f}x")

go deeper

for a junior

Remember that turning a profiler on makes the program slower, so the seconds printed are not the seconds users experience. Use the report to see where time goes, not to quote latency.

for a middle

Explain the mechanism: a deterministic profiler does fixed bookkeeping on every call and return, so the error grows with call count rather than with work. Be able to describe the with-and-without comparison that detects it.

for a senior

Show the diagnostic sequence on a real case: compare profiled and unprofiled totals, read ncalls beside the times, re-profile at a coarser boundary, and confirm the fix outside the profiler before claiming a win.

for a principal

Own the measurement policy: which evidence is allowed to justify optimisation work, when a deterministic profiler must give way to a sampling one, and how the team avoids spending sprints on rows that were artefacts of the instrument.

### What "deterministic" costs `cProfile` is a deterministic profiler: it is notified on **every** call and **every** return, plus every call into a built-in, and it does bookkeeping at each of those events — reading a clock, finding or creating the entry for that code object, updating counters, and maintaining its own view of the stack. The C implementation makes that bookkeeping small, but it is not free, and crucially it is a **fixed cost per event**, not a percentage of the work. That is the whole distortion in one sentence: the profiler's error is proportional to your **call count**, while the thing you are trying to measure is proportional to your **work**. A function that runs for 50 milliseconds per call absorbs the overhead invisibly. A function that runs for a microsecond per call and is called two million times can easily spend more time being profiled than being executed — and, because the overhead is charged at the call boundary, it lands on exactly that function's row and on its callers' `cumtime`. ### The invoice-PDF scenario Take a renderer with a 45-second cold start. A profile blames a one-line helper that encodes a single character, called several million times because the text layer falls back to a per-character encoding path whenever a glyph is missing from the chosen font — an encoding mismatch between the document's text and the font's coverage. The report says that helper owns most of the `tottime`. Three readings are possible, and only measurement separates them: 1. **The helper really is the cost.** Millions of Python-level calls are genuinely expensive, and the fix is structural: encode whole strings instead of characters, or resolve the font fallback once. 2. **The helper is cheap and the profiler is loud.** The real cost is elsewhere, and the helper only floated to the top because the per-event overhead multiplied by its call count outweighs everyone else's honest time. 3. **Both.** The commonest outcome. The discriminator is simple: run the same work **without** the profiler and compare totals. If an unprofiled run takes 45 seconds and the profiled run takes 45 seconds, the profile is close to truthful. If the profiled run takes three or four times as long, a large slice of what you are reading is instrumentation, and the per-call seconds are fiction — though the **ranking** usually survives, because the overhead scales with call count and call count is itself a real property of the program. ```python import cProfile, time def encode_glyph(ch): return ch.encode("latin-1", "replace") def render(n): return sum(len(encode_glyph(c)) for c in "invoice" * n) t0 = time.perf_counter() render(200000) plain = time.perf_counter() - t0 prof = cProfile.Profile() t0 = time.perf_counter() prof.runcall(render, 200000) profiled = time.perf_counter() - t0 print(f"plain {plain:.3f}s profiled {profiled:.3f}s ratio {profiled / plain:.1f}x") ``` ### What to do about it **Profile a coarser unit.** If the inner loop is call-heavy, move the profiled boundary outward — profile a whole page render rather than a glyph — so the number of instrumented events drops by orders of magnitude and the fixed cost stops dominating. You lose fine attribution and gain trustworthy stage-level totals, which is usually the decision you actually need first. **Read `ncalls` alongside the times.** A row with an enormous call count and a tiny per-call cost is the profiler's blind spot *and* frequently a real design problem: a Python-level call is expensive regardless of who is measuring, so "called two million times" is a finding in its own right, even when the seconds beside it are inflated. **Prefer ratios to seconds.** Compare a before-and-after profile captured the same way, under the same instrumentation. The overhead is roughly constant per event, so it largely cancels and the delta means something even when the absolute numbers do not. **Confirm the win outside the profiler.** Any change justified by a profile must be re-measured without one. It is entirely possible to "optimise" away calls that only mattered because they were instrumented, and to ship a change that is neutral or worse in production. **Know when to switch tools.** A deterministic profiler pays per event; a sampling profiler pays per sample interval, so its cost is bounded by frequency rather than by your program's shape. That makes sampling the right instrument when per-call overhead is the thing corrupting the measurement, or when the process must keep serving while you look at it. The failure to avoid is treating the report as a stopwatch. `cProfile` is an attribution tool. It is excellent at telling you which region of a program is responsible for the time and roughly how the cost is distributed, and it is unreliable as a source of absolute per-call latencies in exactly the regime — many tiny calls — where people most want to believe it.

  • How can you tell from the report itself that instrumentation is dominating?
    Look for rows with an enormous `ncalls` and a minute per-call time — that is where the fixed per-event cost lands. Then compare the profile's total against a plain wall-clock run of the same work: a profiled run several times slower means the absolute numbers are inflated even though the ordering is probably still informative.
  • What do you give up by profiling a coarser unit of work?
    Attribution. You stop seeing which small helper is hot and only learn which stage owns the time. The usual compromise is two passes: a coarse run to identify the stage honestly, then a narrow profile of just that stage, where you accept the distortion because you only need relative ranking inside it.
  • Does cProfile show time spent inside C code?
    It records calls into built-ins and C functions as their own rows, so their cost is attributed and visible. What it cannot do is look **inside** one C call: a long-running extension call appears as a single expensive entry with no internal breakdown, which is why an unexplained built-in row is sometimes the end of what this tool can tell you.

It is like timing a sprinter by making him stop and sign a logbook at every stride. The runner who takes the most strides looks slowest, and the stopwatch total is meaningless — but the relative ordering of who takes the most strides is still real information.

saying these in an interview costs you the question

  • Treats the profiled seconds as production latency
  • Never compares the run against an unprofiled baseline
  • Assumes overhead is a flat percentage of run time
  • Optimises a row that only mattered because it was instrumented
  • Thinks a deterministic profiler samples the stack
  • Ships a performance claim measured only under the profiler

context