Frontend Labs
⋯

31. Performance: measure first, then vectorize, cache, or go native

31.1 The order of operations

  1. Measure. Find where time actually goes. It is almost never where you guessed.
  2. Fix the algorithm. O(n²) → O(n log n) beats any micro-optimization (the in list → in set change from Ch 6.1).
  3. Use the right built-in or library. sum, sorted, str.join, collections.Counter, heapq, and NumPy are C code.
  4. Vectorize numeric work (Ch 53).
  5. Cache repeated work (Ch 14.3).
  6. Parallelize (Ch 27–29).
  7. 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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36