Performance & profiling¶
At a glance
Priority: P1 · Complexity: ⅗ · Est. time: 3 h · Phase: 5 · Prereqs: asyncio in depth, CPython internals You're done when: given a slow Python service you can localise the bottleneck (CPU vs I/O vs loop-blocking vs memory) with the right profiler in under 30 minutes, propose a fix with a measured before/after, and explain why the fix works at the CPython level.
Why it matters¶
In LLM systems the model call dominates latency, so teams skip performance work, until the service collapses at 200 RPS because JSON serialisation, Pydantic validation, tokenisation, or one blocking call in the event loop eats the CPU. Tail latency, cost (pods) and cold-start time are all Python-level problems. The Staff skill is measure first, classify the bottleneck, then pick the cheapest lever (algorithm, data structure, batching, caching, native library, concurrency model), not "rewrite in Rust".
Core concepts¶
Method: classify before optimising¶
flowchart TD
A[Slow?] --> B{Where is time going?}
B -->|wall >> CPU| C[Waiting: I/O, locks, blocked loop]
B -->|CPU high| D[Compute: py-spy / cProfile / scalene]
B -->|RSS growing| E[Memory: tracemalloc / memray]
C --> C1["asyncio debug, loop-lag metric, py-spy dump, tracing spans"]
D --> D1["algorithm, data structure, vectorise, native lib, process/free-thread"]
E --> E1["leaks, caches without bounds, big intermediates"]
Always get a repeatable benchmark and record baseline numbers. Without them you're guessing.
Tool selection¶
| Question | Tool | Notes |
|---|---|---|
| What is this running process doing right now? | py-spy dump --pid N, py-spy top |
Sampling, attaches without code changes, low overhead. Works on production pods (needs ptrace capability) |
| Where is CPU time? (flame graph) | py-spy record -o flame.svg --pid N |
Add --native for C extensions, --gil to see only GIL holders |
| Line-level and CPU vs memory vs GPU | scalene |
Also separates Python time from native time and copy volume |
| Deterministic call counts/timings of a script | cProfile + snakeviz/pstats |
Adds overhead (2-3x) and biases towards call-heavy code |
| Readable wall-time tree, async-aware | pyinstrument |
Sampling. Good for FastAPI request profiling via middleware |
| Memory allocations by line | tracemalloc (stdlib), memray |
memray gives flame graphs and tracks native allocations |
| Micro-benchmarks | timeit, pyperf |
pyperf does calibration, multiple processes, and statistical checks |
| Linux perf integration | python -X perf (3.12+) |
Lets Linux perf see Python function names (3.12+; see the HOWTO) |
| Bytecode-level | dis |
Explains why a rewrite is faster (CPython internals) |
Note on 3.15: a dedicated profiling package (PEP 799) with a sampling profiler (profiling.sampling) is planned for Python 3.15, released around 2026-10-01. It's not available on 3.14, so use py-spy now.
Event-loop health (async services)¶
- Symptom: p99 spikes across all endpoints, CPU on one core at 100%, low overall CPU.
- Detect: a loop-lag probe:
import asyncio, time
async def loop_lag_probe(interval: float = 0.1, report=print) -> None:
while True:
t = time.perf_counter()
await asyncio.sleep(interval)
lag = time.perf_counter() - t - interval
if lag > 0.05:
report(f"loop lag {lag*1000:.0f} ms") # export as a histogram metric in production
- Localise with
py-spy dump(which coroutine/function is on-CPU) orasyncio.run(main(), debug=True)("Executingtook 0.510 seconds"). - Fix:
to_thread, process pool, or algorithmic fix (asyncio).
CPython-level performance intuition (verified on 3.14.5, Apple silicon)¶
| Micro-fact | Measured | Takeaway |
|---|---|---|
x in list (10k items) vs x in set |
106 us vs 0.05 us | Data structure choice beats micro-tuning by 1000x+ |
loop with append vs list comprehension (10k items) |
291 us vs 253 us | Comprehensions ~13% faster (bytecode specialisation, no method lookup) |
100k small objects: plain class vs __slots__ |
9.6 MB vs 5.6 MB | Slots save ~40% memory per small instance |
s += str(i) in a loop (20k) vs "".join |
2.2 ms vs 1.7 ms | CPython optimises in-place concat when refcount is 1, so it's not quadratic here, but that is an implementation detail. Use join |
Re-run these in your environment (lab). Numbers shift with version, CPU and build flags.
Version notes: 3.11 (specializing adaptive interpreter, "faster CPython"), 3.12 (comprehension inlining, perf trampoline), 3.13 (experimental JIT and free-threaded builds), 3.14 (tail-call interpreter with Clang 19+ builds, roughly 3-5% on pyperformance, opt-in; JIT still experimental; free-threaded penalty ~5-10%). Upgrading Python is a cheap, real speedup. Measure with your workload.
Levers, in order of leverage¶
- Algorithm and data structure (sets/dicts, avoid O(n²), heap/bisect, precompute).
- Do less: cache (bounded!), batch calls (embedding 64 texts per request), avoid repeated (de)serialisation, don't validate trusted data twice, stream instead of materialising.
- Vectorise / native: NumPy, Polars, DuckDB, orjson/msgspec,
pydantic-core(already Rust), tokenizers-rs (data tooling). - Concurrency model: asyncio for I/O, processes or free-threaded threads for CPU (concurrency models).
- Startup/import time:
python -X importtime, lazy imports (PEP 810 explicit lazy imports arrive in 3.15),defer_buildfor Pydantic models,UV_COMPILE_BYTECODE, trim dependencies. Matters for serverless/scale-to-zero LLM workers. - Extensions: Cython, mypyc, PyO3/maturin (Rust), only for proven hot loops after 1-5.
JSON and validation costs (LLM services)¶
- Big response bodies: serialise once, avoid Pydantic→dict→JSON hops (
model_dump_json), considerorjson/msgspecfor hot paths after measuring. model_validate_jsonon bytes is faster thanjson.loads+model_validate(measured ~30% on a 50-item model in 3.14/2.13, see Pydantic).- Logging at DEBUG with large prompts: string formatting cost is paid even if the log is dropped unless you use lazy
%sargs orisEnabledForguards.
Memory¶
tracemalloc.take_snapshot()diffs find growth by line.memray run -o out.bin app.pythenmemray flamegraph out.bin.- Typical leaks: unbounded
lru_cache/dict caches, per-requestAsyncClients, retained exceptions/tracebacks (frames keep locals alive), global lists of message histories,@cacheon methods, reference cycles with__del__. - Fragmentation and RSS not shrinking after peaks is normal (allocator behaviour), and not always a leak. Compare
tracemalloc(Python heap) to RSS. - GC note: 3.14.0-3.14.4 shipped an incremental GC that was reverted in 3.14.5 because of memory-pressure reports. 3.14.5+ uses the generational GC from 3.13.
gc.freeze()after warm-up (pre-fork) reduces copy-on-write churn. Tune thresholds only with evidence.
Senior nuance¶
- Profilers lie differently: cProfile inflates function-call-heavy code, sampling profilers miss short-lived functions, and
time.perf_countermicro-benchmarks lie about cache effects. Triangulate. - Benchmark in production-like conditions: same Python build/flags (PGO/LTO builds vs distro builds differ by 10%+), same data shape, warmed caches. Docker
python:3.14-slimis a PGO build. Alpine (musl) has had different perf and wheel availability. - Tail latency: look at p99/p999, GC pauses, noisy neighbours, connection-pool exhaustion, and DNS. Use tracing spans to attribute time by phase (queue wait, validation, LLM, post-processing) (observability).
- A faster function that runs once per request behind a 2 s LLM call is irrelevant. Attribute by share of the request, not by hotspot aesthetics.
- Amdahl: if serialisation is 8% of CPU, a 2x speedup buys 4%. Focus on the biggest slice.
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| py-spy | tool | Sampling profiler for live production processes | intermediate | free |
| Scalene | tool | CPU + memory + native/Python split per line | intermediate | free |
| memray | tool | Best memory profiler, including native allocations | intermediate | free |
| pyinstrument | tool | Readable async-aware wall-clock profiles | intermediate | free |
| Python perf profiling support | docs | perf with Python frames |
advanced | free |
| pyperf | docs | Trustworthy benchmarking methodology | advanced | free |
| Itamar Turner-Trauring: Python speed and memory articles |
article | Practical, data-processing-oriented performance writing | intermediate | free |
| What's New in 3.14 (performance sections) | docs | Version-by-version interpreter changes | intermediate | free |
| tracemalloc docs | docs | Stdlib memory tracing | intermediate | free |
| Pydantic performance tips | docs | Validation hot-path guidance | intermediate | free |
Hands-on lab¶
Goal (90 min): find and fix three planted bottlenecks in one service.
- Create a FastAPI endpoint
/scorethat (a) loads a 50k-itemlistof allowed IDs and checksid in allowedfor 2,000 IDs, (b) callstime.sleep(0.05)insideasync def, © builds a 5 MB response withjson.loads(json.dumps(...))round trips. - Load test with
hey -z 20s -c 50or locust and note p50/p99 baseline. uvx py-spy record -o flame.svg --pid <uvicorn pid>andpy-spy dump. Expected: the flame graph showslist.__contains__(a) andtime.sleep(b) dominating, plus JSON time ©.- Add
loop_lag_probe, and observe lag ~50 ms bursts under load from (b). - Fix one at a time, re-measure after each (list to set: 100 us to 0.05 us per lookup at 10k items; sleep to
await asyncio.sleeporto_thread; serialise once). Record the table: baseline, after each fix, p99. - Memory: add an unbounded dict cache keyed by prompt. Run
memray/tracemallocsnapshots before/after 10k requests and find the growth line. python -X importtime -c "import app" 2> imports.txtand sort by cumulative time to find the slowest import. Try lazy-importing it.- Stretch: run the same benchmark on 3.13 and 3.14 and compare.
Questions¶
L1 - Recall¶
Q1. Sampling vs deterministic profilers: trade-offs?
Answer
Deterministic (cProfile) hooks every call/return: exact call counts but 2-3x overhead and distortion towards call-heavy code. Sampling (py-spy, pyinstrument, scalene) periodically inspects stacks: low overhead, safe on production, statistically accurate for long-running hotspots, but can miss very short functions.
Q2. Why is x in some_list a red flag in a hot path?
Answer
It's O(n) linear scan. On 10k items it took ~106 us vs ~0.05 us for a set in our measurement (2,000x+). Use a set/dict for membership, or bisect on sorted data.
Q3. What does python -X importtime tell you and why does it matter for AI services?
Answer
Per-module import time (self and cumulative). Heavy imports (torch, transformers, pandas, large Pydantic model graphs) dominate cold start in serverless/scale-to-zero and slow CLI tools and tests. Fix with lazy imports, dependency trimming and defer_build.
Q4. Which memory tool tracks native (C/Rust extension) allocations?
Answer
memray (and scalene to a degree). tracemalloc tracks only allocations made through Python's allocator, so it can miss memory held by native libraries.
L2 - Apply¶
Q5. p99 doubles on all endpoints every few seconds, but CPU is 35% overall. Diagnose.
Answer
Classic event-loop blocking (one core saturated in a single-threaded loop, low aggregate CPU on multi-core pods). Add a loop-lag probe to confirm, use py-spy dump/record to find the blocking function (sync SDK call, big JSON, CPU loop, regex backtracking), and move it to to_thread/process pool or fix the algorithm. ruff ASYNC rules prevent recurrence.
Q6. Memory grows ~50 MB/hour in a FastAPI service. Walk through finding the cause.
Answer
Confirm it's Python heap vs native/fragmentation by comparing RSS to tracemalloc. Take snapshots at intervals (tracemalloc.take_snapshot().compare_to(...)) or run memray attach/memray run --live. Look for unbounded caches (lru_cache(maxsize=None), module dicts), per-request clients/sessions, retained exception tracebacks, and conversation histories in globals. Fix with bounded caches (TTL/LRU), lifespan-scoped clients, and eviction. Verify with a soak test.
Q7. Rewrite for speed and explain: result = ''; for r in rows: result += fmt(r).
Answer
"".join(fmt(r) for r in rows) (or a list then join). Repeated concatenation may be optimised in CPython when the refcount is 1 (so not always quadratic), but that's an implementation detail that fails for attributes/other refs and other interpreters. Join is O(n) by design and slightly faster (1.7 ms vs 2.2 ms on 20k items in our test).
Q8. A benchmark shows function A 8% faster than B with timeit number=1000 once. Do you ship the change?
Answer
Not on that evidence. Repeat with pyperf or multiple timeit.repeat runs, look at min and variance, use realistic inputs, warm caches, pinned CPU frequency, and compare on the production Python build. An 8% microbenchmark delta is often noise and rarely matters unless it's a large share of request time.
L3 - Design & trade-offs¶
Q9. Service is CPU-bound in Pydantic validation and JSON at 3k RPS. Options and order of attack?
Answer
1) Confirm with py-spy (share of CPU). 2) Remove redundant work: validate once at the edge, skip validating internal trusted models, return pre-serialised bytes. 3) Use model_validate_json (bytes) and avoid dict hops, reuse TypeAdapters, drop Python-level validators. 4) Evaluate orjson/msgspec for hot serialisation only. 5) Scale out (more pods) if cheaper than engineering time. 6) If still bound: process pool or 3.14t threads for CPU parts, or a Rust extension for the single hottest transform. Each step with before/after numbers and a cost per 1k requests.
Q10. Rewrite a hot Python loop in Rust (PyO3) vs use Polars/NumPy vs Cython vs leave it. Decide.
Answer
First, is the loop expressible as vectorised ops? Polars/NumPy usually win with the least maintenance. If it's irregular logic (parsers, custom scoring), PyO3/maturin gives large speedups (10-100x) with memory safety, but adds a toolchain, wheels matrix (including cp314t), and hiring/skills cost. Cython: fewer new-language concerns for Python teams but weaker safety and tooling. Leave it if it's <5% of request time. Decide with profile share × speedup vs maintenance cost, and keep the pure-Python implementation as the tested reference.
Q11. How should performance be part of CI for a service with strict p99 targets?
Answer
Deterministic micro-benchmarks for critical functions (pyperf/pytest-benchmark) with generous thresholds and trend tracking rather than hard gates (CI noise), a load-test smoke stage on a pinned environment measuring p50/p99 and allocation, a loop-lag and RSS soak in nightly runs, and production SLO dashboards with regression alerts after deploys. Profile-in-prod (py-spy on demand, continuous profiling with Pyroscope-like tools) to attribute regressions. Bench data shapes should mirror production.
L4 - Staff-level ambiguity¶
Q12. Cloud bill for the Python AI platform is up 60% QoQ. Leadership asks you to 'make it faster'. Approach?
Answer
Reframe to cost per unit of value. Break down cost by service, component and resource (compute vs LLM tokens vs vector DB). LLM spend usually dominates: caching, model routing, prompt trimming (cost & latency) beat CPU tuning. For compute: continuous profiling across the top-5 services, find shared hot paths (serialisation, validation, logging), fix in the shared library for org-wide gains, right-size pods (CPU limits vs loop-bound single-thread), and upgrade Python (3.12 to 3.14 free wins). Set a target (cost per 1k requests) and report weekly with attribution.
Q13. Two teams disagree: 'rewrite the hot service in Go' vs 'optimise Python'. Structure the decision.
Answer
Define success metrics (p99, cost per request, team velocity). Profile to establish where time goes and the theoretical ceiling from optimisations (Amdahl). Estimate optimisation results (algorithmic, native libs, 3.14t) with prototypes measured on production traffic replay. Estimate rewrite cost (months of dual-run, loss of the Python AI ecosystem, hiring, on-call ownership) and risk. Often the answer is "optimise + extract one CPU-heavy component". Rewrites are justified when CPU-bound work dominates, the ceiling in Python is insufficient, and the team can own the new stack. Document in an ADR with revisit criteria.
Real-world use cases¶
- Chat gateway p99 regression: loop-lag metric plus py-spy found synchronous tiktoken-style tokenisation on the loop. Moved to a thread pool, p99 dropped 60%.
- Ingestion cost: switching from per-document Pydantic validation to batched Arrow/Polars transforms cut CPU by 5x for tabular metadata.
- Cold starts: lazy imports and trimming a transitive dependency cut worker start from 9 s to 3 s, enabling scale-to-zero.
- Memory leak: unbounded prompt-cache dict in a long-lived agent process, found via tracemalloc diff, replaced by TTL LRU.
Pitfalls & anti-patterns¶
- Optimising without a profile or baseline.
- Micro-optimising code that's <5% of request time.
- Profiling in dev with tiny data, then extrapolating.
- Trusting cProfile numbers for I/O-heavy async code.
- Unbounded caches as "quick fixes".
- Assuming
--workers Nfixes a blocked loop (it hides it). - Ignoring Python/interpreter upgrades as a cheap lever.
Checklist¶
- I can pick the right profiler for CPU / wall / memory / loop-lag problems
- I ran the lab, recorded baseline vs after-fix numbers for three bottlenecks
- I read a py-spy flame graph and a
tracemallocdiff - I answered all L3 questions out loud in < 3 min each