Module 08: Profiling and Performance Testing
Why this matters
Module 05 gave you the procedure for finding a bottleneck — measure the top- level symptom, decompose the request, follow the biggest number. This module gives you the instruments that make the decomposition real: a profiler that tells you exactly where CPU time goes, a flame graph that shows it at a glance, and a load test that reveals how the system behaves under concurrent traffic rather than in a single hand-timed request. Without these, "follow the biggest number" is aspirational — you're back to guessing which function is slow.
The payoff is the recurring promise of the whole back half of the track: the
actual bottleneck is very often not the assumed one. Engineers routinely spend
days optimizing the function they think is slow, ship it, and see no
improvement — because a profiler would have shown the time was somewhere else
entirely (a surprise json.dumps, an accidental N+1 hidden behind an ORM
attribute, a regex compiled on every call). A profiler replaces a day of confident
wrong work with five minutes of correct diagnosis. This module is deliberately a
light intro to load testing — deep load/stress/soak testing is a large topic
that earns fuller treatment later in the curriculum — but you'll finish able to
profile a real FastAPI service, read its flame graph, drive load at it, and
interpret the two together to name the real bottleneck.
Concepts
Two kinds of profiler: deterministic vs sampling
A profiler answers "where does the time go?" There are two mechanisms, with opposite tradeoffs:
- Deterministic (tracing) profilers — e.g. Python's built-in
cProfile— instrument every function call and return, recording exact call counts and time spent. Pros: exact call counts (a function called 1,240,000 times is the N+1 smoking gun, unmissable), precise per-function timing, deterministic results. Cons: the instrumentation itself adds overhead (often 2–5× slowdown), which distorts timings and makes it unsuitable for production (you can't afford to 3× your prod latency) and can mislead on very fast functions where the measurement cost rivals the work. Best for: profiling a specific slow function or endpoint in development. - Sampling profilers — e.g.
py-spy— periodically (say 100×/second) interrupt the program and record the current call stack. Over enough samples, the fraction of samples a function appears in ≈ the fraction of time spent there (statistically). Pros: very low overhead (a few percent), so it runs against production and live processes without instrumenting them —py-spycan even attach to an already-running PID without modifying or restarting it. Cons: statistical, not exact (rare/fast events may be under-sampled; no exact call counts), and needs enough runtime to gather samples. Best for: "this production service is slow right now, what's it doing?" and any profiling where you can't afford tracing overhead.
The rule: cProfile when you can reproduce it in dev and want exact numbers;
py-spy when it's in production or you need to attach to a live process cheaply.
They're complementary, not competitors.
Reading cProfile output
cProfile produces a table. The columns that matter:
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.001 0.001 2.104 2.104 handler.py:10(get_report)
500 0.004 0.000 1.980 0.004 repo.py:22(load_orders)
500 1.850 0.004 1.850 0.004 {method 'execute' of ...} <- 500 queries!
1 0.100 0.100 0.120 0.120 render.py:5(serialize)
ncalls— how many times the function was called. The N+1 detector: a function you expected to call once showing up 500× is the whole diagnosis in one column.tottime— total time spent in this function itself, excluding time in functions it called. Hightottime= the work is here.cumtime— cumulative time in this function including everything it called. Highcumtimebut lowtottime= this function is slow because of what it calls, not itself (follow the call chain down).percall—tottime/ncalls(per-call self time) andcumtime/ncalls.
Reading strategy: sort by cumtime to find the slow path from the top; sort
by tottime to find the slow function doing the actual work; and scan ncalls
for anything called a surprising number of times. The example above screams
N+1: execute called 500× with 1.85s total tottime — the bottleneck is the
number of queries, not any one being slow (exactly module 05's N+1 shape, now
visible in the profiler).
Flame graphs: the whole profile at a glance
A flame graph visualizes a (usually sampling) profile as stacked bars:
- The x-axis is proportion of samples (≈ proportion of time), NOT time order. Width = how much total time was spent in that function and its children. A wide box is where the time went; a narrow box is cheap. (Do not read left-to-right as chronological — the ordering is typically alphabetical or by merge, meaningless as a timeline.)
- The y-axis is stack depth — each box sits on top of its caller. The box at the bottom is the entry point; boxes above are what it called, and what those called, up the stack.
- You read it top-down for width. Scan across the top for wide plateaus — a wide box high up in the stack is a function where the program spends a lot of time at the leaf (doing actual work, not just calling down). That plateau is your bottleneck. Following its tower down shows the call path that led there.
width = time (proportion of samples) ─────────────────────►
depth │ ┌──────────────── get_report ────────────────┐
▲ │ ┌── load_orders ──────────────┐ ┌ serialize ┐
│ │ ┌──── execute ───────────────┐│ └───────────┘
deeper │ │▓▓▓▓ WIDE plateau = hot ▓▓▓▓││ ◄─ the bottleneck:
stacks │ └─────────────────────────────┘ widest leaf box
(x-axis is NOT time order — read for WIDTH, not left-to-right)
The skill is one glance: the widest towers/plateaus are where the time is. A
flame graph makes "80% of the time is in this one call path" visually obvious in a
way a 500-row cProfile table doesn't. py-spy record -o out.svg produces one
directly from a live process; speedscope renders them interactively.
A related view, py-spy top, is a live, htop-style rolling display of which
functions are consuming CPU right now in a running process — perfect for a quick
"what is this stuck server doing?" without generating a full graph.
async and multi-process caveats
Two things trip people up profiling real backends:
- Profiling async code: a coroutine spends most of its time
awaiting (idle), and a CPU-sampling profiler samples the stack, so an I/O-bound async handler shows lots of time in the event loop / awaiting — which is correct (it is waiting) but not a "hot" CPU function to optimize. When profiling async, distinguish CPU time (what a sampler catches at the leaves — real computation to optimize) from wall-clock wait (the awaits — an I/O bottleneck to attack with concurrency/caching/query fixes, module 05's utilization check).py-spy's--idleflag controls whether idle/waiting threads are included. - Profiling multiple processes/workers: a production FastAPI runs several
uvicorn/gunicornworker processes.cProfilesees only the process it's in;py-spycan target a specific PID, and with--subprocessesfollow children. Profile a representative worker, and remember load must actually reach the one you're watching.
Load testing: measuring behavior under concurrency
A profiler tells you where time goes in one execution; a load test tells you how the system behaves under many concurrent requests — which is a different and essential question, because module 05's throughput problems (queuing, pool exhaustion, the stampede) only appear under concurrency. A single hand-timed request can look perfect while the system falls over at 50 RPS.
The vocabulary (kept light — the deep version comes later in the curriculum):
- Load test: apply a realistic, sustained request rate/concurrency and measure the response. The outputs you care about are module 05's metrics: throughput (RPS achieved), latency distribution (p50/p95/p99, not average), and error rate (requests failing/timing out).
- The core relationship (Little's Law, informally): as offered load rises, throughput rises until a resource saturates; past that point throughput plateaus (or drops) and latency climbs steeply as requests queue. Finding that knee — the max sustainable throughput before latency explodes — is the main goal of a basic load test.
- Types (named for orientation, not covered deeply here): a load test at expected traffic; a stress test past the limit to see how it fails; a soak/ endurance test at sustained load for hours to catch leaks (module 06's slow strangulation) and memory growth. This module does the first; the curriculum's later dedicated treatment does the rest.
The essential discipline, same as module 05: report percentiles and error rate, drive realistic concurrency, and change one thing at a time. A load test whose result is "average latency was 30ms" told you almost nothing.
Closing the loop: profile + load test → the real bottleneck
The two instruments combine into the payoff. The workflow:
- Load test to reproduce the problem under realistic concurrency and get the top-level numbers (p99, RPS, errors) — confirming there's a real problem and whether it's latency- or throughput-shaped (module 05, step 1).
- Profile a representative worker while the load runs (attach
py-spyto its PID, orcProfilea reproduced single slow path) to see where the time actually goes (step 2 — decompose). - Read the flame graph / cProfile table for the widest plateau / highest
cumtime/tottime/ surprisingncalls— the measured bottleneck, which is frequently not what you assumed. - Fix that one thing with the right tool from the track (a cache, an eager-load/batch, a concurrency fix, a leak fix, an atomic op) and re-load- test to prove the top-level metric moved (steps 4–5).
┌──────────────────── the profiler-guided loop ───────────────────┐
│ load test ──► profile ──► find the ──► fix that ONE │
│ (get p99/RPS) (under load) widest plateau thing │
│ ▲ │ │
│ └──────────── re-load-test ◄────────────────── ┘ │
│ (did the top-level metric actually move?) │
└── stop when fast enough; the bottleneck MOVES after each fix ────┘
The whole point, restated: you do not optimize what you believe is slow; you optimize what the profiler shows is slow. The gap between those two is where most wasted performance work lives.
Command reference
| Tool / command | Purpose | Example |
|---|---|---|
python -m cProfile -s cumtime script.py |
Deterministic profile, sorted by cumulative time | one-shot script profiling |
cProfile.Profile() + pstats |
Programmatic profiling of a code block | wrap a slow function |
pstats.Stats(p).sort_stats('tottime').print_stats(15) |
Top 15 by self-time | find the hot function |
py-spy top --pid <PID> |
Live top-style view of a running process | "what's it doing now?" |
py-spy record -o prof.svg --pid <PID> --duration 30 |
Record a flame graph from a live process | production profiling |
py-spy record -o prof.svg -- python app.py |
Launch + record a flame graph | dev profiling |
py-spy dump --pid <PID> |
One-shot stack dump of every thread | a hung/stuck process |
--idle / --subprocesses (py-spy) |
Include waiting threads / follow workers | async / multi-worker |
hey -z 30s -c 50 <url> |
Load test: 50 concurrent for 30s, prints latency dist | throughput + p99 |
wrk -t4 -c100 -d30s <url> |
Higher-throughput load test | many connections |
locust (locustfile.py) |
Scriptable, scenario-based load testing in Python | realistic user flows |
Profiling a code block programmatically and printing the useful sorts:
import cProfile, pstats, io
def profile(fn, *args, **kwargs):
pr = cProfile.Profile()
pr.enable()
result = fn(*args, **kwargs)
pr.disable()
s = io.StringIO()
stats = pstats.Stats(pr, stream=s).sort_stats("cumtime")
stats.print_stats(15) # slow *paths* (top-down)
stats.sort_stats("tottime").print_stats(15) # slow *functions* (actual work)
print(s.getvalue())
return result
profile(generate_report, report_id=42) # -> scan ncalls for surprises, cumtime for the path
A minimal locust scenario (scriptable load with realistic behavior):
# locustfile.py — run: locust -f locustfile.py --headless -u 50 -r 10 -t 30s --host http://localhost:8000
from locust import HttpUser, task, between
class ApiUser(HttpUser):
wait_time = between(0.1, 0.5) # think-time between requests
@task(3) # weight 3: hit the report 3x as often
def report(self):
self.client.get("/report?quarter=Q3®ion=EU")
@task(1)
def health(self):
self.client.get("/health")
Hands-on exercises
pip install py-spy locust and a load tool (hey/wrk). Reuse a FastAPI +
Postgres + Redis app from earlier modules; a deliberately slow endpoint is ideal
(you'll build the fully-slow one in the capstone). On Linux, py-spy attaching to
a PID may need sudo or --cap-add SYS_PTRACE in Docker.
1. cProfile a slow script and read the table
Write a script that calls a function doing an N+1-style loop (500 tiny queries or
500 tiny function calls). Run python -m cProfile -s cumtime script.py and also
-s tottime.
Expected: sorted by cumtime, the top is your entry function (it contains
everything); sorted by tottime, the real work surfaces; and ncalls shows the
inner call happening 500×. Practice pointing at the exact line and column that
proves it's an N+1 (the 500 in ncalls), not a single slow call.
2. Distinguish tottime from cumtime
Profile a function outer() that spends 0.01s itself but calls inner() which
takes 2s. Read both columns for outer.
Expected: outer has low tottime (0.01s — it barely works itself) but high
cumtime (2.01s — because of inner). Conclude the rule: high cumtime + low
tottime means "follow the call chain down"; high tottime means "the work is
right here." This is how you navigate a deep profile without getting lost.
3. Generate and read a flame graph with py-spy
Start a small program with a known hot function (a wide plateau) plus some cheap
ones. Run py-spy record -o prof.svg -- python app.py (or attach to its PID).
Open the SVG in a browser.
Expected: the hot function is a wide box/plateau; the cheap ones are narrow slivers. Confirm the x-axis is proportion, not time order (the boxes aren't chronological), and that following the wide box downward shows its call path. You've read a flame graph the correct way: widest = where the time is.
4. Attach py-spy to a live server without restarting it
Start your FastAPI app (uvicorn app:app), note its PID (ps/docker top),
drive some load at a slow endpoint, and in another terminal run py-spy top --pid <PID>.
Expected: a live, rolling view of which functions are eating CPU right now — without you having added any instrumentation or restarted the process. This is the production superpower: diagnosing a running, un-cooperative service in place. Note how little it slows the server (sampling overhead is a few percent), unlike cProfile.
5. Load test and read the distribution, not the average
Point hey -z 30s -c 50 http://localhost:8000/<slow-endpoint> at your service.
Read the full output: RPS, and the latency histogram/percentiles.
Expected: a throughput number and a latency distribution — note the gap between
p50 and p99 (the tail from module 05). Deliberately compute the average from the
histogram and confirm it hides the p99 pain. Then raise -c (concurrency) and
watch RPS plateau while p99 climbs — the saturation knee.
6. Find the saturation knee
Run the load test at -c 1, 5, 10, 25, 50, 100 (or a locust ramp). Tabulate
RPS and p99 at each.
Expected: RPS rises with concurrency until a resource saturates, then plateaus
while p99 climbs steeply — the knee. Identify (with docker stats / a py-spy on
the worker) which resource saturated (app CPU? DB CPU? pool exhausted?). You've
measured the system's real capacity, not just its best-case single-request speed.
7. Profile under load to find the real bottleneck
While exercise 5's load runs, attach py-spy record to the worker for 30s and
open the flame graph. Compare what the graph says is the widest plateau to what
you assumed was slow before looking.
Expected: often a surprise — the widest plateau is something you didn't expect (serialization, an accidental N+1 behind an ORM attribute, a per-request regex compile, connection setup). Write down the assumed bottleneck vs the measured one. This gap is the module's thesis.
8. Diagnose and fix: the flame graph that contradicts the assumption
A /search endpoint is slow under load. The team is sure it's the database and
has spent a day tuning indexes with no improvement. You attach py-spy under load
and the flame graph shows: ~8% of samples in asyncpg (the DB), ~15% in
json.dumps, and a ~70%-wide plateau in a function highlight_matches that,
you find, compiles a regular expression with re.compile(...) inside a loop over
every result on every request. Diagnose, prescribe the fix, and explain the
team's process error.
Answer
The flame graph flatly contradicts the assumption: the database is only ~8% of
the time, so no amount of index tuning could meaningfully help (Amdahl's Law,
module 05 — you can't win more than ~8% by optimizing an 8% component, which is
exactly why the day of index work did nothing). The real bottleneck is the
~70% plateau in highlight_matches, caused by compiling the same regex on
every iteration of a per-result loop on every request — pure CPU waste repeated
N×M times. Fix: compile the regex once (module-level, or once per request
before the loop) and reuse the compiled pattern — turning thousands of
re.compile calls into one. Secondary: the 15% in json.dumps suggests an
oversized payload (module 06 — field-selection/compression) worth a follow-up
once the plateau is gone. Process error: they assumed the bottleneck (the DB)
and optimized it for a day without ever profiling — a five-minute py-spy
flame graph would have shown the DB was 8% and the regex compile was 70% before
they touched a single index. Profile first; optimize what the profiler shows, not
what you believe.
Independent challenge
No code given. Take the slowest endpoint in your app (or build a deliberately
slow one — you'll need exactly this for the capstone). First, load test it
with hey/wrk/locust and record the honest top-level numbers: RPS, p50/p95/
p99, and error rate at a realistic concurrency, and find the saturation knee by
ramping concurrency. Second, before profiling, write down your guess at the
bottleneck. Third, profile it — cProfile for a reproduced single slow path
to get exact ncalls/tottime/cumtime, and py-spy attached to the live
worker under load to get a flame graph — and identify the widest plateau /
highest self-time / most surprising call count. Fourth, compare the measured
bottleneck to your guess and note the gap. Fifth, apply the single right fix from
the track (caching, eager-load/batch, a concurrency/async fix, a leak fix, an
atomic op, a compile-once) and re-load-test to prove the top-level p99 and RPS
actually moved. Keep every before/after number and the flame graphs. Reach back to
05-performance-bottlenecks-and-identifying-them: this is that module's
procedure, now executed with real instruments instead of hand-timing.
Hint
Use the two profilers for what each is good at: cProfile when you can reproduce
the slow path in dev and want exact call counts (the ncalls column is the N+1
smoking gun) and precise self-vs-cumulative time; py-spy when you want to attach
to the live server under load with negligible overhead and get a flame graph
whose widest plateau is the bottleneck (remember the x-axis is proportion of
time, not chronological order). Profile while the load test runs so you catch
behavior under concurrency, not a cold single request. The likely surprises — the
places the measured bottleneck diverges from the guess — are serialization
(json.dumps on a fat payload), an accidental N+1 hidden behind a lazy ORM
attribute (a function with a huge ncalls), per-request work that should be done
once (a compiled regex, a re-created client), or an event-loop-blocking call
(module 07). Whatever it is, fix only that, then re-load-test to prove the
top-level metric moved — an improvement in a profiler percentage that doesn't show
up in p99/RPS wasn't the real bottleneck.
Common mistakes & troubleshooting
- Optimizing without profiling. The cardinal sin — a day tuning the assumed bottleneck that a five-minute profile shows is 8% of the time (exercise 8). Profile first, always.
- Using cProfile in production. Its tracing overhead (2–5×) is unacceptable
on a live service and distorts timings. Use
py-spy(sampling, low overhead) in production; cProfile in dev. - Reading a flame graph left-to-right as a timeline. The x-axis is proportion of time, not chronological order. Read it for width (widest = hottest), not sequence.
- Confusing
tottimeandcumtime. Highcumtime/lowtottimemeans the function is slow because of what it calls (go down); hightottimemeans the work is in it. Sort by both. - Ignoring
ncalls. A function called a shocking number of times is an N+1 or repeated-work bug — often the whole diagnosis, and easy to miss if you only look at time columns. - Reporting average latency from a load test. The average hides the tail; report p50/p95/p99 and error rate. An "average was fine" load test is nearly worthless.
- Load testing at unrealistic concurrency (or just
-c 1). Single-request timing misses queuing, pool exhaustion, and stampedes — the throughput problems only appear under concurrency. Drive realistic load and find the knee. - Profiling the wrong worker / no load reaching it. In a multi-worker server,
attach to the worker actually handling traffic (or use
--subprocesses), and make sure load reaches the process you're sampling. - Treating a profiler improvement as done without re-load-testing. A better profile percentage that doesn't move the top-level p99/RPS wasn't the real bottleneck — always close the loop by re-measuring the user-facing metric.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What's the difference between a deterministic (tracing) profiler like cProfile and a sampling profiler like py-spy, and when do you use each?
- In cProfile output, what do
ncalls,tottime, andcumtimeeach tell you, and how do you usetottimevscumtimeto navigate a deep profile? - On a flame graph, what do the x-axis and y-axis represent, and how do you find the bottleneck at a glance? What must you NOT infer from the x-axis?
- Why can py-spy profile a production service when cProfile can't, and what can py-spy do to a running process that's especially useful for a stuck server?
- Why is a load test necessary in addition to a profiler — what class of problem only appears under concurrency, and what happens to throughput and latency as offered load passes the saturation point?
- Describe the combined workflow (load test + profile) for finding the real bottleneck, and state the module's thesis about assumed vs measured bottlenecks.
Answers
- A deterministic/tracing profiler (cProfile) instruments every call/return for exact counts and timings but adds heavy overhead (2–5×), so it's for dev profiling of a reproducible slow path. A sampling profiler (py-spy) periodically records the stack for statistical results with low overhead (a few %), so it can run in production and attach to live processes. Use cProfile when you can reproduce it and want exact numbers; py-spy in production or to attach cheaply to a live/stuck process.
ncalls= how many times the function was called (a surprising count is the N+1 detector);tottime= time in the function itself excluding callees (the work is here);cumtime= cumulative time including callees. Navigate by sorting oncumtimeto find the slow path from the top and following high cumtime/low tottime functions down to their expensive callees, and sorting ontottimeto find the function doing the actual work.- X-axis = proportion of samples/time spent in a function and its children (width = how hot); y-axis = stack depth (each box sits on its caller). Find the bottleneck by scanning for the widest boxes/plateaus (that's where the time is) and following that tower down for the call path. You must NOT read the x-axis as a timeline — the horizontal order is not chronological.
- cProfile's tracing overhead (2–5×) would cripple a live service and distort its
timings; py-spy samples with only a few percent overhead, so it's safe in prod.
Especially useful: py-spy can attach to an already-running PID without
restarting or modifying it (
py-spy top/dump) — so you can see what a stuck/slow production process is doing right now in place. - A profiler measures one execution; throughput problems — queuing, connection- pool exhaustion, cache stampedes, lock contention — only manifest under concurrent load, and a fine single-request timing can hide a system that collapses at 50 RPS. Past the saturation point, throughput plateaus (or drops) while latency climbs steeply as requests queue behind the saturated resource — the knee a load test is designed to find.
- (1) Load-test to reproduce the problem under realistic concurrency and get top-level p99/RPS/error numbers; (2) profile a representative worker while the load runs (py-spy attach, or cProfile a reproduced path); (3) read the flame graph / table for the widest plateau / highest tottime-cumtime / surprising ncalls — the measured bottleneck; (4) fix that one thing and re-load-test to prove the top-level metric moved. Thesis: the real bottleneck is frequently not the assumed one — optimize what the profiler shows is slow, not what you believe is slow.
Further reading & sources
- Python: cProfile / the Python Profilers - the built-in deterministic profiler and the
ncalls/tottime/cumtimecolumns. - py-spy - the low-overhead sampling profiler that attaches to live processes and records flame graphs.
- Brendan Gregg: Flame Graphs - the definitive guide to what flame graphs are and how to read them.
- Locust documentation - scriptable, scenario-based load testing in Python.
- speedscope - an interactive flame-graph viewer for profiles from py-spy and others.
- Wikipedia: Little's Law - the throughput/concurrency/latency relationship behind the saturation knee.
Next
09-capstone-project — you now have every tool the track teaches: caching at every layer and strategy, eviction and invalidation, the bottleneck-hunting procedure, batching and leak-avoidance and offloading, concurrency vs parallelism, and the profilers and load tests to find the truth. The capstone hands you a deliberately, realistically slow FastAPI + Postgres + Redis service and asks you to profile it, find the top three bottlenecks, and fix each with the right technique — with before/after numbers required.