Performance, Vectorization, Memory Layout and Strides
NumPy is fast, but only when you use it the NumPy way. This lesson measures the difference between slow and fast code, and then looks under the hood at how an array is stored in memory (layout and strides), which explains why some code is faster than other code.
How to run the examples
Create a new file named performance_memory.py in your NumpyTutorials folder (see the setup in lesson 01). Any name works; this one just matches the topic. Start the file with these lines:
import time
import numpy as np
Then copy the examples of this page into the same file, one after another, in the order shown, and run it with python performance_memory.py (with your virtual environment active). Some examples use variables created in earlier ones, so keep everything in the one file. Each Output box shows what that example prints.
Add this small helper after the imports. timed() runs a function a few times and reports the best time in milliseconds; prices is one million random prices used in the first examples:
def timed(function, repeat=5):
"""Run a function a few times and return the best time in milliseconds."""
best = float("inf")
for _ in range(repeat):
start = time.perf_counter()
function()
best = min(best, time.perf_counter() - start)
return best * 1000
rng = np.random.default_rng(0)
prices = rng.uniform(10, 1000, size=1_000_000)
The timings below are from one run on one computer. On yours the numbers will be different, but the comparisons (which version is faster, and by roughly how much) stay the same.
1. Python loop vs vectorized
def with_loop():
result = np.empty_like(prices)
for i in range(len(prices)):
result[i] = prices[i] * 1.18
return result
def vectorized():
return prices * 1.18
loop_ms = timed(with_loop, repeat=1)
vector_ms = timed(vectorized)
print(f"loop : {loop_ms:8.1f} ms")
print(f"vectorized : {vector_ms:8.1f} ms")
print(f"speed-up : about {loop_ms / vector_ms:.0f}x")
print("same result:", np.allclose(with_loop(), vectorized()))
Both versions add 18% GST to one million prices and give the same result, but the loop is dozens of times slower. In the loop, Python handles every single element as a separate Python object, one at a time. prices * 1.18 hands the whole job to NumPy's compiled C code, which runs through one block of memory in a tight loop.
2. if/else in a loop vs np.where
def discount_loop():
return [p * 0.9 if p > 500 else p for p in prices]
def discount_where():
return np.where(prices > 500, prices * 0.9, prices)
print(f"loop : {timed(discount_loop, repeat=1):8.1f} ms")
print(f"np.where : {timed(discount_where):8.1f} ms")
Loops with an if inside are the most common slow code. np.where(condition, a, b) (lesson 05) does the same "10% off above 500" rule for every element at once.
3. Use built-in functions
print(f"sum() with Python : {timed(lambda: sum(prices), repeat=1):8.2f} ms")
print(f"prices.sum() : {timed(prices.sum):8.2f} ms")
print(f"np.sqrt(x) : {timed(lambda: np.sqrt(prices)):8.2f} ms")
print(f"x ** 0.5 : {timed(lambda: prices ** 0.5):8.2f} ms")
- Python's
sum()on an array still goes element by element.prices.sum()is many times faster. The same is true formax,minand others: always use the NumPy method. - Specialised functions are often faster than general ones:
np.sqrt(x)beatsx ** 0.5.
4. In-place operations
a = rng.random(5_000_000)
def new_arrays():
return a * 2 + 1 # makes 2 temporary arrays
buffer = np.empty_like(a)
def in_place():
np.multiply(a, 2, out=buffer) # writes into buffer
np.add(buffer, 1, out=buffer) # same as buffer += 1: no new array
return buffer
print(f"a * 2 + 1 : {timed(new_arrays):6.2f} ms")
print(f"out= and += : {timed(in_place):6.2f} ms")
print("same result :", np.allclose(new_arrays(), in_place()))
a * 2 + 1 creates a temporary array for a * 2 and then another one for + 1. With 5 million values that is 40 MB of new memory, twice. Writing into an existing array with out= (or +=, *=) avoids those temporary arrays, which saves memory and time. Use this in loops that repeat a calculation many times.
5. Smaller dtypes
big = rng.random(5_000_000)
small = big.astype(np.float32)
print(f"float64: {big.nbytes / 1e6:5.0f} MB sum time {timed(big.sum):5.2f} ms")
print(f"float32: {small.nbytes / 1e6:5.0f} MB sum time {timed(small.sum):5.2f} ms")
print("float32 is less exact:", big[:3], "->", small[:3])
float32 uses half the memory of float64, and less memory to read often means faster code. The price is precision: float32 keeps about 7 significant digits instead of about 16 (look at the second value). Use float32 for big data where that is enough, such as images, sensor data and machine learning, and keep float64 for money and science.
6. Strides: how an array sits in memory
Memory is one long line of bytes. A 2D array is stored as one block, row after row. NumPy uses two pieces of information to find any element:
- shape: how many rows and columns there are.
- strides: how many bytes to jump to get to the next row, and to the next column.
m = np.arange(12, dtype=np.int64).reshape(3, 4)
print(m)
print("shape :", m.shape)
print("itemsize:", m.itemsize, "bytes")
print("strides :", m.strides, "-> 32 bytes to the next row, 8 bytes to the next column")
print("flags C_CONTIGUOUS:", m.flags["C_CONTIGUOUS"], " F_CONTIGUOUS:", m.flags["F_CONTIGUOUS"])
print("\ntranspose m.T strides:", m.T.strides, "(same memory, read the other way)")
print("m.T C_CONTIGUOUS :", m.T.flags["C_CONTIGUOUS"])
print("\nevery 2nd column m[:, ::2] strides:", m[:, ::2].strides, "(jump 16 bytes)")
print("shares memory with m:", np.shares_memory(m, m[:, ::2]))
- Each element is 8 bytes (
int64). The next column is 8 bytes further on, and the next row is 4 elements x 8 = 32 bytes further on. So strides =(32, 8). m.Tdoes not move any data; it just swaps the strides to(8, 32). That is why transposing is instant (lesson 07).m[:, ::2](every 2nd column) also just changes a stride, to 16 bytes, and shares memory withm. That is why slices are views (lesson 06).C_CONTIGUOUSis True when the elements sit in memory exactly in row-by-row order, with no gaps.
7. C order vs F order
c_array = np.arange(6).reshape(2, 3) # C (row by row)
f_array = np.asfortranarray(c_array) # F (column by column)
print("same values:", np.array_equal(c_array, f_array))
print("C strides :", c_array.strides, " memory order:", c_array.ravel(order="K"))
print("F strides :", f_array.strides, " memory order:", f_array.ravel(order="K"))
fixed = np.ascontiguousarray(m.T) # copy into C order
print("ascontiguousarray(m.T) C_CONTIGUOUS:", fixed.flags["C_CONTIGUOUS"], fixed.strides)
- C order (NumPy's default) stores rows one after another: 0 1 2 | 3 4 5.
- F order (Fortran, MATLAB) stores columns one after another: 0 3 | 1 4 | 2 5.
- Both arrays have the same values; only the memory order and strides differ.
np.ascontiguousarraymakes a C-ordered copy, for example of a transposed array, which some libraries require.
8. Reading along memory is faster
grid = rng.random((4000, 4000)) # C order
print(f"sum of every ROW (memory order) : {timed(lambda: [grid[i].sum() for i in range(4000)], repeat=2):7.1f} ms")
print(f"sum of every COLUMN (jumping) : {timed(lambda: [grid[:, j].sum() for j in range(4000)], repeat=2):7.1f} ms")
The grid is in C order, so each row is one continuous stretch of memory, and the processor reads it very efficiently. A column is spread out: every value is 4000 x 8 = 32,000 bytes away from the next one, so the processor keeps jumping around. The same amount of work is several times slower. When you loop over a big array, loop over the last axis (rows in C order), or better, use one vectorized call with axis=.
Exercises
Try these exercises on your own before moving on:
- Time a loop that squares 1,000,000 numbers and compare with arr ** 2.
- Print the strides of np.zeros((5, 6), dtype=np.float32). Explain them.
- Print the strides of np.zeros((5, 6), order="F").
- Check if arr[::3] shares memory with arr.
Summary
- Replace Python loops with whole-array operations; use
np.whereinstead of if/else in loops. - Use NumPy's own functions (
arr.sum(),np.sqrt) instead of Python's. - In-place operations (
out=,+=) avoid temporary arrays. - Smaller dtypes such as
float32halve memory but reduce precision. - Strides are the byte jumps to the next row and column; transposes and slices only change strides.
- C order stores rows together, F order stores columns together; reading along memory is fastest.