Every experiment you run per day is a function of how fast your code is, and research velocity compounds like interest. The gap between idiomatic and naive numerical Python is not 20% — it is two orders of magnitude, and it comes from exactly two mechanisms you can learn in an afternoon: the interpreter tax and the memory hierarchy. This lesson explains both, states the broadcasting rules precisely (once, so the guessing stops), and then teaches the only reliable optimization method ever discovered: measure first.
The 100×: where it actually comes from
import numpy as np
a, b = np.random.rand(10_000_000), np.random.rand(10_000_000)
def loop(a, b):
out = np.empty_like(a)
for i in range(len(a)):
out[i] = a[i] * b[i]
return out
# %timeit loop(a, b) # ~2.5 s
# %timeit a * b # ~15 ms — the same arithmetic, ~150× faster
Two taxes are being refunded. The interpreter tax: each trip through a
Python loop body costs bytecode dispatch, type checks, and boxing floats
into heap objects — tens of nanoseconds of overhead wrapping a
sub-nanosecond multiply. a * b pays that overhead once and runs a
compiled C loop over raw doubles, which the CPU further accelerates with
SIMD (8+ multiplies per instruction). The memory tax: performance for
big arrays is bounded by bytes moved, not FLOPs (the GPU lesson’s
memory-bound regime — it rules CPUs too). RAM arrives in 64-byte cache
lines, so touching memory sequentially gets 8 doubles per line and lets
the prefetcher stream; striding or hopping wastes 7/8 of every fetch.
That second tax has a famous fingerprint: layout. NumPy arrays are
row-major (C order) — rows contiguous. Summing a large matrix along rows
vs columns is the same arithmetic with different stride patterns, and the
timing difference (often 2–5× at sizes beyond cache) is pure memory
hierarchy. The general habits: iterate/reduce along the contiguous axis
when you can, know that transpose/swapaxes are free (they change
strides, not data) until something forces a copy, and reach for
arr.copy(order="C") when a hot loop will re-read a badly-laid-out view
many times.
Broadcasting, stated precisely
The rules — all of them, there are only two: align shapes from the right; two dimensions are compatible if equal or either is 1, and size-1 dimensions are (virtually — no copy) stretched to match.
X = np.random.rand(500, 3) # (500, 3)
mu = X.mean(axis=0) # (3,)
Xc = X - mu # (500,3)−(3,) → aligns right → OK
col = np.random.rand(500) # (500,)
# X - col # (500,3)−(500,): 3 vs 500 → ERROR (a good one)
Xr = X - col[:, None] # (500,1) → stretches to (500,3): what you meant
D = ((A[:, None, :] - B[None, :, :]) ** 2).sum(-1) # all pairwise distances,
# (n,1,d)−(1,m,d)→(n,m,d): the idiom behind every kernel/distance matrix
The habit that catches 90% of silent bugs: comment expected shapes and
assert the ones that matter. The worst broadcasting outcome is not the
error — errors are gifts — it is the (500,1) vs (500,) subtraction that
succeeds with shape (500,500) and a wrong answer. (keepdims=True on
reductions exists to prevent exactly this.) When an expression needs three
Nones and a transpose, np.einsum("ij,kj->ik", A, B) says the same
thing legibly — and everything in this section transfers verbatim to
torch/JAX, where it becomes GPU speed as well as CPU speed.
The default is the everyday case: (500, 3) minus (3) aligns right, the 3s match, the missing axis stretches — centering a dataset, no copies made. Now type the classic trap: A = (500,) and B = (500, 1). It SUCCEEDS — result (500, 500), a quarter-million elements from what you meant to be a paired subtraction, and no error will ever tell you. Then try (8, 1, 6) against (7, 1) and watch both shapes stretch in different axes — legal, useful, and exactly the pairwise-distance idiom from the code above. Ten minutes of deliberately breaking this widget buys you years of not debugging silent shape bugs; the readout’s element count is also your early warning for the accidental gigabyte.
Profiling: the discipline of not guessing
Decades of evidence, one sentence: programmers’ guesses about where time goes are usually wrong, and optimization without measurement is superstition. The working ladder:
%timeitfor micro-questions (is A faster than B?) — it handles warmup and repetition; never trust a singletime.time()delta.- cProfile → snakeviz for the macro map: which functions own the
time. First finding, in most ML code: it’s the dataloader / IO / a
pandas
apply, not the math you were beautifying. - line_profiler (
@profile+kernprof) for the micro map: which line in the hot function. This is where the accidental O(n²) — alist.indexin a loop, a repeatedpd.concat, an unintended copy per iteration — gets caught red-handed. - GPU work needs its own stopwatch: kernels launch asynchronously, so
wall-clocking a line measures the launch, not the work — call
torch.cuda.synchronize()around timings or usetorch.profiler/Nsight, or your profile is fiction.
And when a loop is genuinely irreducible — dynamic programming, per-element
state, simulation steps — the escape hatch is compilation, not cleverness:
numba @njit for numeric loops (near-C speed for the cost of a
decorator), Cython for library-grade code, or restructuring the algorithm
so the loop moves into NumPy/torch after all. The priority order stands:
correct → measured → vectorized → compiled, and only ever the measured-hot
part. Readability is a feature; a 3× speedup of 2% of runtime is a
negative-value patch.
Exercises
Work these before the next lesson
- Refund the interpreter tax yourself: time the loop-vs-vectorized elementwise multiply at n = 10³, 10⁵, 10⁷. Why does the RATIO grow with n and then plateau? Name the two regimes.
Solution
Worked solutions are part of Premium — unlock all of them for £5/month →
- 5 more exercises — each with a worked solution — are part of Premium. Unlock everything for £5/month →
References
- NumPy documentation, “Broadcasting” and “Internals” — strides and layout from the source.
- U. Drepper, “What Every Programmer Should Know About Memory”, 2007 — the memory hierarchy, definitively.
- J. VanderPlas, Python Data Science Handbook, ch. 2 — vectorization idioms.
- numba.pydata.org — the @njit escape hatch’s honest docs (what it can and cannot compile).