open atlas
↑ Back to track
Python for JS/TS developers PY · 08 · 01

Profiling with cProfile and py-spy: measure the production shape before touching code

cProfile hooks every call: 1.3-2x overhead that inflates tiny hot functions and cannot see inside C. py-spy samples stacks from outside via ptrace at ~1%: top, record flamegraphs, dump for stuck processes. timeit flatters. Profile prod-shaped workloads and re-measure p99 after.

PY Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The checkout API ran p99 at 480 ms against a 200 ms SLO. cProfile on a dev box pointed at compute_discounts — 38% of cumulative time — so the team spent a sprint memoizing and restructuring it. The microbenchmark said 6x faster. The deploy moved p99 from 480 to 465 ms. Then someone ran py-spy top --pid against a live production worker: 61% of samples sat inside JSON serialization of a 2 MB response — a cost the dev profile never saw, because the dev fixture used a 2 KB synthetic payload and because cProfile’s per-call hook had inflated the thousands of tiny compute_discounts helper calls while attributing the C serializer’s work to one opaque row. A serializer swap and response trimming later, p99 was 170 ms. The sprint was lost to a profiler that was trusted, run on the wrong workload, in the wrong process.

In ten minutes you will know exactly why cProfile can point at the wrong function — and how py-spy on a live process finds what cProfile never saw.

cProfile: deterministic tracing, and what the overhead distorts

cProfile is a deterministic tracer: it registers a C-level profile hook (the lsprof machinery behind sys.setprofile) that fires on every function call and return, recording counts and timers per function. Nothing is missed — that is the appeal — but every call pays a fixed bookkeeping tax, so the whole program typically runs 1.3-2x slower under it. The distortion is not uniform. The fixed per-call cost is enormous relative to a 200 ns helper invoked ten million times and negligible relative to a 50 ms database query invoked twice. Tiny hot Python functions therefore look worse than they are, and the “top offenders” ranking bends toward call count rather than real cost.

Reading the output is its own skill: tottime is self time (excluding callees), cumtime is inclusive. Sort by cumtime to find the expensive subtree, then by tottime inside that subtree to find where the cycles actually burn. pstats slices programmatically; snakeviz renders the same data as a browsable sunburst.

import cProfile, pstats

cProfile.run("handle_request(req)", "out.prof")
p = pstats.Stats("out.prof")
p.sort_stats("cumulative").print_stats(12)  # find the expensive subtree
p.sort_stats("tottime").print_stats(12)     # find the self-cost inside it

Two blind spots are structural. First, C internals are invisible: json.dumps or a numpy op appears as a single opaque row — total time, zero breakdown of what happens inside. Second, threads: by default cProfile observes the thread it was started in; worker threads run unprofiled unless each installs its own profiler. Under asyncio, the event loop’s run method swallows everything into one cumtime and per-coroutine attribution gets murky.

Quiz

cProfile shows `validate_row` (a 15-line helper, 10 M calls) as the top tottime entry in your service profile. What is the disciplined next step?

py-spy: sampling from outside the process

py-spy inverts the deal. It runs as a separate process that reads the target’s memory — process_vm_readv/ptrace on Linux, mach APIs on macOS — locates the interpreter’s thread states, and walks the frame structs to reconstruct every Python stack, about 100 times per second. No code change, no import, no restart: attach by PID to a process started long before you knew it had a problem. The target is paused for microseconds per sample, so the overhead lands around 1% — safe against production.

Three subcommands cover the field workflow:

  • py-spy top --pid 4137 — live htop-style view of which functions own the samples right now.
  • py-spy record -o prof.svg --pid 4137 --duration 60 — a flamegraph; add --native to interleave C frames (this is how the Hook’s serializer became visible), --idle to include blocked threads.
  • py-spy dump --pid 4137 — a one-shot stack of every thread: the stuck-process tool. A worker at 0% CPU that stopped consuming its queue is diagnosed in seconds — you see exactly which lock acquire or socket read every thread is parked on.

The honest caveat: sampling is statistical. A 5 ms spike that happens once a minute may never be sampled at 100 Hz; you need enough wall time on the behavior you are chasing, and rare-event latency needs tracing, not profiling.

timeit, and the lies of microbenchmarks

timeit disables GC and reports the best of N repeats — by design it measures the warm-cache, turbo-boosted ideal of an isolated snippet. A real service runs the same code with cold caches, interleaved work evicting branch predictors, frequency scaling, and a fragmented allocator. A 30% timeit win can be invisible at p99. Use it to compare two implementations of the same small operation under identical lies; never to predict service latency.

The discipline

When you reach for a profiler, ask first: does this environment match production? Real payload sizes, real concurrency, real data distributions, real cache hit rates. A synthetic loop on a dev box is a different program that happens to share source code. The working order: py-spy against production (or replayed prod traffic) to find where; cProfile and timeit locally to iterate on why; then re-measure p99 after the deploy — the profiler is evidence, the SLO metric is the verdict.

Quiz

A production worker sits at 0% CPU, has stopped consuming its queue, and responds to nothing. First diagnostic move?

Recall before you leave
  1. 01
    Contrast cProfile and py-spy: mechanism, overhead, and what each cannot see.
  2. 02
    Your microbenchmark says the optimized function is 6x faster, but production p99 did not move. List the measurement mistakes that produce this outcome.
Recap

cProfile and py-spy answer the same question with opposite contracts. The tracer hooks every call from inside: complete, deterministic, but 1.3-2x slow, biased against tiny hot functions in proportion to their call count, blind inside C, and single-threaded by default — read it as cumtime for subtrees and tottime for self-cost, through pstats or snakeviz. The sampler reads the interpreter’s frame structs from another process via ptrace: ~1% overhead, no restart, production-safe, C frames available under —native, with top for live attribution, record for flamegraphs, and dump as the stuck-process x-ray — its only tax is statistics, so rare events need patience or tracing. timeit measures a warm-cache best case with GC off and is honest only when comparing two snippets under identical conditions. The meta-skill outranks all three tools: profile the production shape — payloads, concurrency, data distribution — because a synthetic loop is a different program, and judge success by the SLO metric after deploy, not by the benchmark that justified the change.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.