NumPy·Lesson 24·12 min·0/3 exercises

Making it fast

Kill the loops, stop copying, reuse buffers — in that order.

out=in-place opsnp.vectorizetemporarieswhere=contiguityfloat32

Watch it happen

Play it through, or step back and forth yourself.

Python for loop
420 msone object per element
list comprehension
310 msstill Python-level
np.vectorize
280 msa loop wearing a costume
a * 2 (vectorised)
8 mscompiled C over a block
doubling 1,000,000 values · indicative timings

Doubling a million values four ways. The gap between the Python loop and the vectorised expression isn't a few percent — it's roughly fifty times, and it widens with size.

The idea

This lesson pulls together strides from lesson 4, views from lesson 6 and vectorisation from lesson 14 into one practical question: when NumPy is slower than you expected, what do you actually do about it?

The honest answer is that there are four levers and they are wildly unequal. Work down the list in order.

1. Remove Python loops

This is the only order-of-magnitude win, and everything else is rounding error next to it. Roughly fifty times, and it grows with array size.

A warning about a function that sounds like the solution and isn't:

np.vectorize(f)(a)     # NOT faster — it loops in Python underneath

NumPy's own documentation calls it a convenience function and says it's provided primarily for readability, not performance. It's genuinely useful for making a scalar function accept arrays and broadcast properly — it just won't make anything quick. If you need a real speed-up for logic that can't be expressed with ufuncs, that's what Numba or Cython are for.

2. Stop copying

Every arithmetic expression allocates. a + b + c looks like one pass and is two: a whole temporary array for a + b, then the result.

d = a + b + c          # two allocations
d = a + b; d += c      # one

Masks copy too. a[a > 0].sum() builds an entire filtered array just to add it up and throw it away. Most reductions take a where= instead:

a[a > 0].sum()         # allocates a filtered copy
a.sum(where=a > 0)     # same answer, no copy

3. Reuse buffers

np.add(a, b, out=a)    # write into memory you already have
np.sqrt(a, out=a)      # most ufuncs take out=
a += b                 # the operator form of the same thing
a *= 2

In a loop that runs thousands of times over large arrays, allocating a fresh output every iteration is real cost — both the allocation and the cache pressure. Pre-allocate once with np.empty_like(a) and write into it.

The catch is lesson 6: in-place writes hit every view of that memory. If someone else is holding a slice, you've just changed their data too.

4. Mind the layout

CPUs read memory in cache lines, so walking forwards is much faster than jumping about. On a C-ordered array, a.sum(axis=1) walks along each row contiguously; a.sum(axis=0) steps a whole row's width each time.

a.flags['C_CONTIGUOUS']      # check before blaming the algorithm
np.ascontiguousarray(a)      # pay the copy once, outside the loop

And dtype is a lever too: float32 is half the bytes of float64, so it moves through cache twice as fast. If you don't need sixteen digits of precision — and for most machine learning you don't — that's a free win.

Measure first

%timeit a * 2                       # in a notebook

from time import perf_counter        # anywhere else
t = perf_counter(); f(); perf_counter() - t

Intuitions about what's slow are usually wrong, and the fix is often somewhere you weren't looking. Measure, change one thing, measure again. Optimising code that wasn't the bottleneck is the most common way to waste an afternoon.

Practice

Write it yourself. The answer is there when you want it.

Putting the kettle on…

Starting up…

Write it yourself

not graded

Make a million floats with np.arange. Time a list comprehension that doubles them, then time big * 2, using perf_counter from time. Print both in milliseconds and the speed-up between them. Finish with big.sum(where=big > 500).

Write something and press Run — the output appears here.

Your turn

3 exercises. Write the code yourself, then press Check — a nudge and the answer are there if you want them.

Sum only the values of cups above 100, using where= instead of building a filtered copy.

your answer

Return cups converted to float32, then its nbytes — half what float64 would take.

your answer

Double every value of a copy of v in place, using np.multiply with out=, and return it.

your answer