Skip to content

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()))
Output
loop       :    196.1 ms
vectorized :      3.1 ms
speed-up   : about 64x
same result: True

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")
Output
loop     :    153.5 ms
np.where :      6.5 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")
Output
sum() with Python       :    70.45 ms
prices.sum()            :     0.54 ms
np.sqrt(x)              :     2.73 ms
x ** 0.5                :     2.63 ms
  • Python's sum() on an array still goes element by element. prices.sum() is many times faster. The same is true for max, min and others: always use the NumPy method.
  • Specialised functions are often faster than general ones: np.sqrt(x) beats x ** 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()))
Output
a * 2 + 1          :  30.92 ms
out= and +=        :  12.02 ms
same result        : True

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])
Output
float64:    40 MB   sum time  3.71 ms
float32:    20 MB   sum time  2.23 ms
float32 is less exact: [0.84009916 0.54354661 0.9780501 ] -> [0.84009916 0.5435466  0.9780501 ]

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]))
Output
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
shape   : (3, 4)
itemsize: 8 bytes
strides : (32, 8) -> 32 bytes to the next row, 8 bytes to the next column
flags C_CONTIGUOUS: True  F_CONTIGUOUS: False

transpose m.T strides: (8, 32) (same memory, read the other way)
m.T C_CONTIGUOUS     : False

every 2nd column m[:, ::2] strides: (32, 16) (jump 16 bytes)
shares memory with m: True
  • 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.T does 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 with m. That is why slices are views (lesson 06).
  • C_CONTIGUOUS is 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)
Output
same values: True
C strides  : (24, 8)  memory order: [0 1 2 3 4 5]
F strides  : (8, 16)  memory order: [0 3 1 4 2 5]
ascontiguousarray(m.T) C_CONTIGUOUS: True (24, 8)
  • 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.ascontiguousarray makes 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")
Output
sum of every ROW    (memory order) :    19.0 ms
sum of every COLUMN (jumping)      :   140.9 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:

  1. Time a loop that squares 1,000,000 numbers and compare with arr ** 2.
  2. Print the strides of np.zeros((5, 6), dtype=np.float32). Explain them.
  3. Print the strides of np.zeros((5, 6), order="F").
  4. Check if arr[::3] shares memory with arr.

Summary

  • Replace Python loops with whole-array operations; use np.where instead 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 float32 halve 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.

Next: Lesson 17: Images as NumPy Arrays.