Part 5 · Advanced Python · Module 39
Performance & Profiling
Fast Python is mostly the result of a disciplined process, not clever tricks: measure, find the
real bottleneck, fix the algorithm, and only then reach for micro-optimizations, caching, vectorization, or native
code. This module teaches that process and the tools behind it, from timeit and
cProfile to 3.15's sampling profiler, plus what the 3.14 interpreter itself does to make your code
faster.
- Follow a measure-first workflow and write trustworthy benchmarks with
timeitandperf_counter - Profile with
cProfileand readpstatsoutput (ncalls, tottime, cumtime) - Preview 3.15's
profilingpackage: deterministic tracing vs the Tachyon sampling profiler, flame graphs, and live mode - Measure memory with
tracemallocand understand whatsys.getsizeofdoes and doesn't count - Prioritize algorithmic improvements, then apply proven micro-optimizations and caching
- Explain the 3.14 interpreter's speed features (specialization, tail-call interpreter, experimental JIT, incremental GC) and check which your build has
- Choose among native options (NumPy, Cython, Rust/PyO3, mypyc) and use 3.15 lazy imports for startup time
Measure first
Donald Knuth's line is usually quoted half-way. The full version: "We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%." The skill is finding that 3%. Programmers are famously bad at guessing where time goes; the slow part is rarely the line that looks slow.
- Define "fast enough": a target with a number ("the report must build in under 2 s for 100k rows"). Without one, optimization never ends.
- Make it correct and tested first. Tests let you optimize fearlessly; every speedup must keep them green.
- Benchmark the realistic workload, so you know the baseline.
- Profile to find where the time actually goes.
- Fix the biggest cost, preferably with a better algorithm or data structure.
- Re-measure, and stop when you hit the target.
Timing code correctly
Choose the right clock:
| Clock | Measures | Use for |
|---|---|---|
time.perf_counter() | Highest-resolution monotonic wall time | Benchmarks and elapsed time |
time.perf_counter_ns() | Same, as integer nanoseconds (no float rounding) | Very short intervals |
time.process_time() | CPU time of this process (excludes sleep/I/O waits) | "Is it CPU or waiting?" |
time.monotonic() | Monotonic wall time, coarser | Timeouts, rate limiting |
time.time() | Calendar time; can jump (NTP adjustments) | Timestamps, never for measuring durations |
Single measurements are noisy: other processes, CPU frequency scaling, caches, and the garbage collector all
interfere. timeit handles the basics: it runs the statement many times per measurement
(number), repeats the measurement (repeat), and disables the GC during timing. Report
the minimum (or median) of the repeats: the minimum is the run with the least interference, the
best estimate of the code's intrinsic cost.
pixi run python -m timeit -s "data = list(range(1000))" "sum(data)"
# 50000 loops, best of 5: 4.1 usec per loop
Profiling with cProfile and pstats
A profiler tells you where time goes across a whole program. cProfile is a
deterministic (tracing) profiler: it records every function call and return, so call counts are exact.
The price is overhead on every call, which inflates the cost of small, frequently called functions. Use it to
find hotspots, not to measure absolute speed.
Reading the output
| Column | Meaning |
|---|---|
ncalls | Number of calls. 12/1 means 12 total calls, 1 primitive (non-recursive) call: recursion. |
tottime | Time spent in this function's own code, excluding functions it called. High tottime = the work is here. |
percall (first) | tottime / ncalls |
cumtime | Time from entry to exit, including callees. High cumtime but low tottime = it's a coordinator; look at what it calls. |
percall (second) | cumtime / primitive calls |
filename:lineno(function) | Where the function is defined; {built-in method ...} entries are C functions. |
Sort by tottime to find where the CPU actually burns; sort by cumtime to see which
high-level operations are expensive. stats.print_callers("is_prime") shows who calls a function, and
print_callees what it calls. In this example most time is in is_prime and its generator
expression, and the list-membership test also shows up: two different fixes (memoize or sieve; use a set).
pixi run python -m cProfile -s cumtime app.py | head -25 # quick look
pixi run python -m cProfile -o app.prof app.py # save for later analysis
pixi run python -m pstats app.prof # interactive browser: sort tottime, stats 10
pixi add snakeviz && pixi run snakeviz app.prof # icicle/sunburst view in the browser
Preview: the profiling package in 3.15
pixi exec --spec "python=3.15" python -m profiling.sampling run app.py # summary table
pixi exec --spec "python=3.15" python -m profiling.sampling run --flamegraph -o flame.html app.py
pixi exec --spec "python=3.15" python -m profiling.sampling run --live app.py # top-like live TUI
pixi exec --spec "python=3.15" python -m profiling.sampling attach --live 12345 # attach to a running PID
pixi exec --spec "python=3.15" python -m profiling.sampling run --mode cpu -r 10khz app.py # CPU time only, 10 kHz
pixi exec --spec "python=3.15" python -m profiling.tracing -s tottime app.py # cProfile under its new name
A flame graph draws every sampled stack as a tower of boxes: the x-axis is the share of samples (not
time order), the y-axis is call depth, and wide boxes are where time goes. You read it top-down for "what is the
CPU doing" and bottom-up for "which entry point is expensive". The --mode option chooses what a
sample counts: wall-clock time (including waiting), CPU time, time holding the GIL, or time handling exceptions.
Tracing (cProfile / profiling.tracing) | Sampling (profiling.sampling) | |
|---|---|---|
| Call counts | Exact | None (only sample counts) |
| Overhead | High for many small calls | Low and roughly constant |
| Accuracy for tiny functions | Distorted by overhead | Statistically fair |
| Attach to a running process | No | Yes |
| Best for | Development, "how many times is this called?" | Production, realistic loads, long runs |
Thinking at the line level
Function-level profiles tell you which function is hot. Inside a long function, you need to know which lines are. Options:
line_profiler(pixi add line_profiler): decorate a function with@profileand runpixi run kernprof -l -v app.pyto get time per line.- Sampling profilers (Tachyon in 3.15, or
py-spy) attribute samples to lines too. - Manual sections: a tiny timer context manager is often all you need.
When you look at a hot line, ask: how many times does it run (move work out of loops), how much does each run
cost (an O(n) operation like x in list inside an O(n) loop is O(n²)), and does it allocate (building
temporary lists or strings)? dis.dis(func) shows the bytecode if you're curious what a line really
does.
Memory: sys.getsizeof and tracemalloc
Memory is performance too: allocation costs time, big working sets fall out of CPU caches, and running out of
RAM means swapping or crashing. sys.getsizeof(obj) returns the size of the object itself in bytes,
shallowly: for a list, the list's header and pointer array, but not the objects it points to.
tracemalloc tracks every allocation made by Python, with the line that made it. Take snapshots
before and after an operation and compare them to find what's eating memory. Its numbers include the full cost of
the objects created, which is what you actually care about.
Algorithmic wins vs micro-optimizations
The biggest speedups come from doing less work: a better algorithm or data structure changes the growth rate, and no amount of micro-tuning competes with that at scale. Replacing an O(n²) inner search with an O(1) set lookup turns a 10-minute job into a 50 ms one. Drag the slider to feel the difference:
Once the algorithm is right, a set of well-known micro-optimizations can still give 1.2–3× in a hot loop. They work because they reduce the number of bytecode instructions, dictionary lookups, or Python-level calls. Use them only in code the profiler flagged; elsewhere, readability wins.
| Technique | Instead of | Why it's faster |
|---|---|---|
| Set/dict membership | x in some_list | Hash lookup O(1) vs linear scan O(n) |
"".join(parts) | s += piece in a loop | One allocation vs repeated copying (CPython sometimes optimizes +=, but don't rely on it) |
| Comprehensions | for loop with .append | Specialized bytecode, no repeated append attribute lookup and call |
Built-ins (sum, min, max, sorted, any, map) | Hand-written loops | The loop runs in C |
| Local variables | Globals or module attributes in a hot loop | Locals are array slots; globals and builtins are dict lookups (the gap is smaller with 3.11+ specialization) |
| Hoist attribute lookups | self.items.append(x) repeated millions of times | Bind append = self.items.append once |
| Move invariant work out of loops | re.compile, len(), conversions inside the loop | Do it once |
collections.Counter/defaultdict | Manual if k in d counting | C-implemented counting |
Generators / itertools | Building big intermediate lists | Less memory, better cache use |
Caching
The fastest computation is the one you don't repeat. Memoization stores results keyed by arguments. The standard library provides it as decorators:
functools.cache: unbounded memoization (simple, but grows forever).functools.lru_cache(maxsize=128): keeps the most recently used results, evicting the rest (M27 builds one);cache_info()reports hits and misses,cache_clear()empties it.functools.cached_property: compute an attribute once per instance (see M36).
Vectorization: a NumPy teaser
For numeric work, the biggest lever is moving the loop out of Python entirely. NumPy stores numbers in compact typed arrays and runs operations over whole arrays in optimized C (often SIMD) loops. The style is called vectorization: express the computation on arrays, not elements. Part 7 (M48) covers it in depth.
The 3.14 interpreter: what CPython does for you
Recent CPython releases made unchanged Python code substantially faster. Knowing how helps you write code the interpreter can optimize, and helps you interpret benchmarks.
- Specializing adaptive interpreter (3.11, PEP 659):
after a few executions, generic bytecodes are replaced by specialized versions (for example,
BINARY_OPbecomes an int-add fast path;LOAD_ATTRbecomes "load from this exact slot") that de-specialize if types change. Type-stable hot loops benefit most. - Tail-call interpreter 3.14: a new way of compiling the
interpreter's main loop, where each bytecode handler is a small C function that tail-calls the next. With
recent Clang (19+) it lets the C compiler generate better code than the giant
switch/computed-goto loop, for a few percent of speedup on typical benchmarks. It's a build-time option (--with-tail-call-interp); your code doesn't change. - Experimental JIT 3.14: a "copy-and-patch" JIT compiler that
stitches pre-compiled machine-code templates for hot traces. 3.14's official Windows and macOS binaries from
python.org include it, disabled by default; enable it with the environment variable
PYTHON_JIT=1. Gains in 3.14 are modest and workload-dependent; sometimes it's slower. Other builds (including many Linux distributions and conda-forge) may not include it at all. - Incremental garbage collection 3.14: the cycle collector does its work for the old generation in small increments, reducing the maximum pause time for programs with large heaps.
PYTHON_JIT=1 pixi run python -c "import sys; print(sys._jit.is_enabled())" # True only on a JIT-capable build
pixi run python -c "import sysconfig; print(sysconfig.get_config_var('CONFIG_ARGS'))" # how was it built?
Native extensions and compilers: an overview
When profiling shows a hot, CPU-bound core that can't be vectorized, you can move it to compiled code. Each option trades build complexity for speed:
| Tool | What you write | Typical gain on hot loops | Trade-offs | Pixi |
|---|---|---|---|---|
| NumPy / vectorization | Array expressions | 10–100× | Only for array-shaped problems | pixi add numpy |
| Numba | Python + @njit decorator | 10–100× on numeric loops | Subset of Python; JIT warm-up; numeric focus | pixi add numba |
| mypyc | Your existing type-annotated Python | 1.5–5× | Needs precise types; some dynamic features unsupported; compiles whole modules | pixi add mypy (ships mypyc) |
| Cython | Python with C type declarations (.pyx or pure-Python mode) | 10–100× with types | Build step; C knowledge helps; great for wrapping C libraries | pixi add cython |
| Rust + PyO3 (maturin) | Rust | C-like speed, safe parallelism | Learn Rust; build tooling; FFI boundary costs | pixi add maturin rust |
| C API / cffi / ctypes | C | C speed | Manual memory and refcounting (C API); most effort | pixi add cffi |
Two rules apply to all of them. First, minimize boundary crossings: calling a compiled function a million times with one number each wastes the gain on call overhead; pass whole arrays or batches. Second, a compiled extension is a distribution burden: wheels per platform, per Python version, and (for free-threading) per ABI (M37). That cost is why "pure Python plus NumPy" is the default until proven insufficient.
Startup time and 3.15 lazy imports
For command-line tools, the dominant cost is often startup: importing modules you may never use on this
run. A CLI with ten subcommands that imports pandas at the top pays ~0.5 s even for --help. Measure
import cost with -X importtime, which prints a tree of import times in microseconds (self and
cumulative):
pixi run python -X importtime -c "import json" 2>&1 | tail -3
# import time: 213 | 3549 | json.decoder
# import time: 177 | 177 | json.encoder
# import time: 173 | 3898 | json
In 3.14 the remedies are manual: import inside the function that needs the module, or use
importlib.util.LazyLoader. Both work, but scatter imports or require boilerplate.
Practice
Write best_of(func, repeat=5, number=10) that calls func() number
times per measurement, takes repeat measurements with time.perf_counter, and returns
the best per-call time in seconds (minimum measurement divided by number). Raise
ValueError if repeat or number is less than 1.
Hint
For each repeat: start = time.perf_counter(), a
loop of number calls, then append time.perf_counter() - start.
Show solution
import time
def best_of(func, repeat=5, number=10):
if repeat < 1 or number < 1:
raise ValueError("repeat and number must be >= 1")
timings = []
for _ in range(repeat):
start = time.perf_counter()
for _ in range(number):
func()
timings.append(time.perf_counter() - start)
return min(timings) / number
This function returns the items that appear more than once, in order of their second occurrence. It's correct but quadratic. Rewrite it to run in linear time with the same output. The test runs it on 40,000 items with a time limit.
Hint
Keep two sets: seen (items met at least once) and
reported (items already added to the result).
Show solution
def duplicates(items):
seen, reported, result = set(), set(), []
for x in items:
if x in seen and x not in reported:
reported.add(x)
result.append(x)
seen.add(x)
return result
Write a decorator memoize that caches results by positional arguments and exposes
wrapper.hits, wrapper.misses, and wrapper.clear() (which empties the
cache and resets both counters). Use functools.wraps. Calls with unhashable arguments must still
work (just don't cache them, and don't count them as hits or misses).
Hint
Store the counters as attributes on the wrapper function. Detect
unhashable arguments with try: hash(args) except TypeError:.
Show solution
import functools
def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
try:
hash(args)
except TypeError:
return func(*args)
if args in cache:
wrapper.hits += 1
return cache[args]
wrapper.misses += 1
result = cache[args] = func(*args)
return result
def clear():
cache.clear()
wrapper.hits = wrapper.misses = 0
wrapper.hits = wrapper.misses = 0
wrapper.clear = clear
return wrapper
sys.getsizeof is shallow. Write deep_sizeof(obj) that adds up
sys.getsizeof for obj and everything reachable through lists, tuples, sets,
frozensets, and dicts (both keys and values), counting each object once (by
id) so shared references and cycles are handled.
Hint
Use an explicit stack and a seen set of ids. For a
dict, push both d.keys() and d.values() items.
Show solution
import sys
def deep_sizeof(obj):
seen, stack, total = set(), [obj], 0
while stack:
current = stack.pop()
if id(current) in seen:
continue
seen.add(id(current))
total += sys.getsizeof(current)
if isinstance(current, dict):
stack.extend(current.keys())
stack.extend(current.values())
elif isinstance(current, (list, tuple, set, frozenset)):
stack.extend(current)
return total
Write hottest(func) that runs func() under cProfile and returns the
name (just the function name, like "slow_part") of the Python function with the
largest tottime, ignoring built-ins (entries whose filename is "~") and
func itself. Use pstats.Stats(profiler).stats, a dict mapping
(filename, lineno, funcname) to (cc, nc, tottime, cumtime, callers).
Hint
Filter stats.stats.items() with
filename != "~" and funcname != func.__name__, then take max by
value[2] (tottime).
Show solution
import cProfile
import pstats
def hottest(func):
profiler = cProfile.Profile()
profiler.enable()
try:
func()
finally:
profiler.disable()
stats = pstats.Stats(profiler).stats
candidates = [
(values[2], name)
for (filename, _, name), values in stats.items()
if filename != "~" and name != func.__name__
]
return max(candidates)[1]
Mini-project: make a slow script 10× faster
Quiz
Recap
- Set a target, keep tests green, benchmark, profile, fix the biggest cost, re-measure, stop.
- Time with
perf_counterandtimeit(min or median of repeats); usepyperffor serious comparisons. cProfile: tottime finds the work, cumtime finds expensive operations; 3.15 addsprofiling.tracingand the Tachyon sampling profiler with flame graphs and live mode.sys.getsizeofis shallow;tracemallocfinds allocating lines.- Algorithms first (sets, dicts, avoiding rescans), then micro-optimizations in hot loops, then caching, vectorization, and native code.
- 3.14's interpreter adds the tail-call interpreter, an opt-in experimental JIT (
PYTHON_JIT=1), and incremental GC (reverted in 3.15). - For startup time, measure with
-X importtime; defer imports, or use 3.15'slazy import.
Going further
- The Python profilers,
timeit,tracemalloc - PEP 799: a dedicated profiling package and PEP 810: explicit lazy imports
- What's New in Python 3.14 (tail-call interpreter, JIT, incremental GC)
- pyperf and the pyperformance benchmark suite
- Next: M40 Standard Library Power Tools