31. Performance: measure first, then vectorize, cache, or go native
31.1 The order of operations
- Measure. Find where time actually goes. It is almost never where you guessed.
- Fix the algorithm. O(n²) → O(n log n) beats any micro-optimization
(the
in list→in setchange from Ch 6.1). - Use the right built-in or library.
sum,sorted,str.join,collections.Counter,heapq, and NumPy are C code. - Vectorize numeric work (Ch 53).
- Cache repeated work (Ch 14.3).
- Parallelize (Ch 27–29).
- Go native for the remaining hot loop: Cython, a Rust extension with PyO3, or Numba.
31.2 Profilers
import cProfile
import pstats
def parse(lines):
return [l.split(",") for l in lines]
def aggregate(rows):
totals = {}
for r in rows:
totals[r[0]] = totals.get(r[0], 0) + float(r[1])
return totals
def job():
lines = [f"user{i % 100},{i * 0.5}" for i in range(300_000)]
return aggregate(parse(lines))
cProfile.run("job()", "prof.out")
pstats.Stats("prof.out").sort_stats("cumulative").print_stats(5)
python -m cProfile -s cumtime app.py # whole program, sorted by cumulative time
uv tool run py-spy top --pid 12345 # sampling profiler: attach to a RUNNING process
uv tool run py-spy record -o flame.svg -- python app.py # flame graph
python -X importtime -c "import pandas" 2>&1 | sort -t'|' -k2 -n | tail # slow imports
cProfile is deterministic and adds overhead to every call; good for
"which function". py-spy samples the stack from outside the process,
with almost no overhead, and works on production processes without
restarting them; it is the Python equivalent of the perf and flame
graphs from OS Ch 70.
Scalene separates Python time, native time, and memory per line. For
micro-benchmarks, use timeit:
import timeit
print(timeit.timeit("''.join(parts)", setup="parts = ['x'] * 1000", number=10_000))
31.3 Cheap wins that are real
# Local variable lookups are faster than global/attribute lookups in hot loops.
import math
def slow(xs):
return [math.sqrt(x) for x in xs]
def fast(xs, sqrt=math.sqrt):
return [sqrt(x) for x in xs]
# Build strings with join, not +=.
# Use generators to avoid materializing huge intermediate lists.
# Use dict/set membership instead of list membership.
# Avoid repeated work inside loops (compile regexes once; hoist invariants).
# Use the batch API: executemany, bulk inserts, one HTTP request with 100 items.
31.4 Going native
When one function dominates and cannot be vectorized:
| Option | What it is | Good for |
|---|---|---|
Numba @njit |
JIT-compiles a numeric Python function to machine code via LLVM | loops over NumPy arrays |
| Cython | Python-like language compiled to a C extension | wrapping C libraries, typed hot loops |
| PyO3 + maturin | write the extension in Rust | safe, fast parsers and data structures; how pydantic-core, polars, ruff, tokenizers are built |
C extension / ctypes / cffi |
call C directly | existing C libraries |
A minimal Rust extension, so you know the shape (from the Rust guide's FFI chapter):
// src/lib.rs (cargo new --lib fastcount; add pyo3 with the "extension-module" feature)
use pyo3::prelude::*;
#[pyfunction]
fn count_bytes(data: &[u8], needle: u8) -> usize {
data.iter().filter(|&&b| b == needle).count()
}
#[pymodule]
fn fastcount(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(count_bytes, m)?)?;
Ok(())
}
uv tool run maturin develop --release # builds and installs into the current venv
python -c "import fastcount; print(fastcount.count_bytes(b'a\nb\nc\n', 10))" # 3
This is why the modern Python toolchain is fast: uv, ruff, polars,
pydantic-core, and Hugging Face tokenizers are Rust with a Python
interface.