Python from First PrinciplesPost 11 of 20 · 20 of 20 written
- How to use this guide
- 01What Python actually is
- 02Names, objects and references
- 03Built-in types and what they cost
- 04Control flow and expressions
- 05Functions in depth
- 06Iteration and generators
- 07Classes and the data model
- 08Context managers and exceptions
- 09Modules, packages, imports and environments
- 10Type hints and Pydantic
- 11The performance model
- 12Concurrency and parallelism
- 13NumPy: the array model everything else copies
- 14Data and serialization
- 15PyTorch mental model
- 16AI systems engineering in Python
- 17Tooling, debugging and testing
- 18Asking the right questions
- 19Roadmap and build-to-learn projects
- 20Glossary and resources
11. The performance model
Python · 11 of 202 min readOct 2026
Python · Post 11 of 202 min readOct 2026
Python is slow per operation, yet AI workloads in Python are fast because the heavy math runs in compiled kernels. Performance work is mostly moving loops out of the interpreter and keeping the hardware busy.
Why pure Python is slow
- Interpretation: every bytecode instruction passes through the interpreter loop.
- Dynamic dispatch:
a + blooks up both types and their addition function at runtime, every time. - Boxing: each number is a heap object with a reference count and a type pointer, not a raw value.
- Pointer chasing: lists hold pointers to objects scattered in memory, so the CPU cache keeps missing.
- One thread at a time: in the default build, only one thread runs Python bytecode at once (the GIL, Part 12).
The result is a gap of roughly two to three orders of magnitude between a pure Python loop and the same math in a vectorized library, and more on a GPU.
Why AI in Python is fast anyway
Python is the control plane; compiled libraries are the data plane.
# Pure Python: one interpreter round trip per element
total = 0.0
for i in range(len(a)):
total += a[i] * b[i]
# One call: the loop runs in compiled code (SIMD, BLAS, or a GPU kernel)
total = a @ bSame math, wildly different speed. Five rules follow from it:
- Vectorize: express work as whole-array operations (Part 13).
- Batch: many small calls cost more than one big call. This holds for GPU kernels, embedding APIs and database writes alike.
- Keep per-element Python out of hot paths:
.item(),.tolist(),.cpu()and Python loops over tensor elements each cross the boundary, and on a GPU they force a synchronization. - Keep data on the device: CPU-to-GPU copies are slow compared with GPU compute.
- Choose the dtype deliberately: float32 or bfloat16 instead of float64 halves memory traffic or better.
Measure, don’t guess
time.perf_counter()measures wall-clock spans.timeit, or%timeitin IPython, runs micro-benchmarks properly.cProfileshows which functions take the time:python -m cProfile -o out.prof script.py, then view it with snakeviz.py-spysamples a running process without code changes. It is the tool for “why is this job stuck?”line_profilergives per-line timings inside one function.tracemallocand memray track Python memory use.torch.profilerrecords a CPU and GPU timeline down to individual kernels.
GPU caveat: CUDA calls are asynchronous. Timing GPU code without torch.cuda.synchronize() measures only the kernel launch, not the work.
Wins, in order of typical payoff
- Fix the algorithm: O(n²) to O(n log n), or list membership to set membership.
- Move loops into libraries: NumPy, PyTorch, polars, pandas.
- Batch I/O and API calls, and use concurrency for the waiting (Part 12).
- Cache repeated work:
functools.cachein memory, or an on-disk cache for embeddings and LLM responses. - Compile:
torch.compilefor models; Numba, Cython or Rust (via PyO3) for custom numeric loops.
Do not optimize code that does not appear in the profile.
Questions to ask:
- Where does the time go according to the profiler, not to intuition?
- Is there a Python loop over elements that could be one array operation?
- Is the GPU waiting on Python, on data loading, or on transfers?