Skip to main content
NumPy · lesson 11 of 13
NumPy advanced Lesson 11 of 13

NumPy Performance Optimization

Measure NumPy performance for real: timeit and tracemalloc, float32 vs float64, memory layout, out= buffers, einsum, and a dtype claim NumPy 2 disproves.

By SkillByExample · Updated

Measure Before Optimizing

timeit gives speed, tracemalloc gives memory — both from the standard library.

import timeit
import tracemalloc
import numpy as np

rng = np.random.default_rng(42)
A = rng.standard_normal((1000, 1000))
B = rng.standard_normal((1000, 1000))

best = min(timeit.repeat(lambda: A @ B, number=10, repeat=3)) / 10
print(f"A @ B: {best * 1000:.2f} ms per call")

tracemalloc.start()
C = A + B
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"A + B peak allocation: {peak / 1e6:.1f} MB")
$ python measure.py
A @ B: 14.20 ms per call
A + B peak allocation: 8.0 MB

Taking the minimum of several repeats filters out background noise. The allocation for A + B is exactly one new 1000 × 1000 float64 array: 8 MB. Numbers throughout this lesson come from one machine; measure on yours before acting on them.

float32 vs float64

import timeit
import numpy as np

X64 = np.random.default_rng(0).standard_normal((2000, 2000))
X32 = X64.astype(np.float32)
print(f"memory: {X64.nbytes / 1e6:.0f} MB vs {X32.nbytes / 1e6:.0f} MB")

t64 = min(timeit.repeat(lambda: X64 @ X64, number=3, repeat=3)) / 3
t32 = min(timeit.repeat(lambda: X32 @ X32, number=3, repeat=3)) / 3
print(f"matmul float64 {t64:.3f} s, float32 {t32:.3f} s, ratio {t64 / t32:.1f}x")

print(np.float32(1) + np.float32(1e-8) == 1, np.float64(1) + 1e-8 == 1)
$ python dtypes.py
memory: 32 MB vs 16 MB
matmul float64 0.111 s, float32 0.050 s, ratio 2.2x
True False

Half the bytes, and on this machine a noticeably faster matrix product — BLAS routines process more float32 values per instruction. The last line is the cost: float32 carries about 7 significant digits, so adding 1e-8 to 1 changes nothing, while float64 still registers it.

A Myth About Upcasting

A common tip says a float32 array silently turns into float64 in (x - x.mean()) / x.std(). Checking it on NumPy 2:

import numpy as np

x = np.arange(5, dtype=np.float32)
print((x - x.mean()) / x.std())
print(((x - x.mean()) / x.std()).dtype)
print((x * 2.5).dtype)
print((x + np.float64(1)).dtype, (x + np.ones(5)).dtype)
$ python promotion.py
[-1.4142135  -0.70710677  0.          0.70710677  1.4142135 ]
float32
float32
float64 float64

The whole expression stays float32: x.mean() of a float32 array is a float32 scalar, and a plain Python float (2.5) adopts the array’s dtype under NumPy 2’s promotion rules. What does upcast is mixing in an explicit NumPy float64 — a scalar or an array — as the last line shows.

Memory Layout and Copies

import timeit
import numpy as np

A = np.random.default_rng(0).standard_normal((3000, 3000))
F = np.asfortranarray(A)
print(A.flags["C_CONTIGUOUS"], F.flags["F_CONTIGUOUS"])

for name, arr in [("C-order", A), ("F-order", F)]:
    t = min(timeit.repeat(lambda: arr.copy(order="C"), number=5, repeat=3)) / 5
    print(f"{name} -> C copy: {t * 1000:.1f} ms")

half = A[:, ::2]
print(half.flags["C_CONTIGUOUS"], np.ascontiguousarray(half).flags["C_CONTIGUOUS"])
$ python layout.py
True True
C-order -> C copy: 18.9 ms
F-order -> C copy: 70.3 ms
False True

Copying a C-ordered array into C order is a straight memory copy; copying the Fortran-ordered version has to reorder every element, which jumps around memory and runs slower. Slicing with a step (A[:, ::2]) produces a non-contiguous view; np.ascontiguousarray makes a compact copy, worth doing once before an array is reused many times.

Reusing Buffers with out=

import timeit
import numpy as np

X = np.random.default_rng(42).standard_normal((2000, 2000))
buf = np.empty_like(X)

def fresh():
    return np.sqrt(X * X + 1.0)

def reuse():
    np.multiply(X, X, out=buf)
    np.add(buf, 1.0, out=buf)
    return np.sqrt(buf, out=buf)

print(np.allclose(fresh(), reuse()))
for f in (fresh, reuse):
    t = min(timeit.repeat(f, number=10, repeat=3)) / 10
    print(f"{f.__name__:5s} {t * 1000:.1f} ms")
$ python out_buffers.py
True
fresh 23.4 ms
reuse 8.2 ms

fresh allocates a new 32 MB array for each of its three steps; reuse writes all three into one preallocated buffer. Same result, and on this run nearly three times faster — for large arrays, allocating and first-touching fresh memory costs about as much as the arithmetic itself. It pays off most in loops that run the same computation many times.

einsum

import timeit
import numpy as np

rng = np.random.default_rng(0)
A = rng.standard_normal((64, 128, 128))
B = rng.standard_normal((64, 128, 128))

C1 = np.einsum("bij,bjk->bik", A, B)
print(C1.shape, np.allclose(C1, A @ B))
print(np.einsum("ii->", np.eye(5)), np.einsum("ij->", np.ones((3, 4))))

for name, f in [("A @ B", lambda: A @ B),
                ("einsum", lambda: np.einsum("bij,bjk->bik", A, B))]:
    print(f"{name:7s} {min(timeit.repeat(f, number=3, repeat=3)) / 3 * 1000:.1f} ms")
$ python einsum_demo.py
(64, 128, 128) True
5.0 12.0
A @ B   6.8 ms
einsum  35.0 ms

einsum writes contractions as index strings: bij,bjk->bik is a batch of matrix products, ii-> is a trace, ij-> sums everything. It is great for clarity, but for a plain batched matrix product the dedicated @ hands the work to BLAS and was about five times faster here. Reach for einsum when the contraction has no single-function equivalent (or try optimize=True, and measure).

When a Loop Is Unavoidable

Some algorithms — a recurrence where each step needs the previous result, or irregular per-item logic — don’t vectorize. The usual answers are a JIT compiler such as Numba (@numba.njit on a plain Python loop) or writing that one function in Cython. Install them separately, and measure them the same way as everything above.

Practice

  1. Measure how long np.sort takes on one million float64 values versus the same values as float32.
  2. Predict the dtype of np.ones(3, dtype=np.int8) + 300, then run it.
  3. Rewrite y = 3 * x + 1 for a large x so that it allocates only one output array.
Solution 1
import timeit
import numpy as np

x64 = np.random.default_rng(0).standard_normal(1_000_000)
x32 = x64.astype(np.float32)
for name, x in [("float64", x64), ("float32", x32)]:
    t = min(timeit.repeat(lambda: np.sort(x), number=5, repeat=3)) / 5
    print(f"{name} sort {t * 1000:.1f} ms")
$ python practice_sort.py
float64 sort 11.6 ms
float32 sort 6.1 ms

Compare the two lines: sorting moves less data for float32, but how much that helps depends on your CPU’s sort implementation.

Solution 2
import numpy as np

try:
    print((np.ones(3, dtype=np.int8) + 300).dtype)
except OverflowError as e:
    print("OverflowError:", e)
$ python practice_int8.py
OverflowError: Python integer 300 out of bounds for int8

Under NumPy 2’s rules a Python int adopts the array’s dtype, and 300 doesn’t fit in int8 (−128 to 127), so NumPy raises instead of silently upcasting. Convert explicitly with .astype(np.int16) first.

Solution 3
import numpy as np

x = np.arange(5.0)
y = np.multiply(x, 3)
np.add(y, 1, out=y)
print(y)
$ python practice_inplace.py
[ 1.  4.  7. 10. 13.]

np.multiply allocates y once; np.add(..., out=y) then updates it in place instead of creating a second temporary.

Frequently Asked Questions

What is the difference between C-order and Fortran-order memory layout?
C-order, NumPy's default, stores each row contiguously in memory; Fortran-order stores each column contiguously. Reading memory in the order it is laid out makes better use of CPU caches, so layout can change the speed of reductions and copies. np.asfortranarray and np.ascontiguousarray convert between the two, and arr.flags shows which one you have.
Does using float32 instead of float64 actually make a difference?
float32 halves memory use and lets SIMD instructions process twice as many values per instruction, so large matrix products and memory-bound operations are often noticeably faster. The cost is precision: about 7 significant digits instead of 16. Deep learning uses float32 or lower by default; statistics and numerical solvers usually stay with float64.
Does mixing a float32 array with a Python float upcast to float64?
Not in NumPy 2. Under the NEP 50 promotion rules, a Python int or float combined with an array takes the array's dtype, so float32_array * 2.5 stays float32. Mixing two NumPy arrays or NumPy scalars of different precision still promotes to the wider type, for example float32 with float64 gives float64.
How do I measure a NumPy operation's speed reliably?
Use timeit.repeat with several repeats and take the minimum, which filters out interference from other processes, or %timeit in Jupyter. Time only the operation, not setup like array creation, and run it on data of the size you really use, because small arrays are dominated by call overhead rather than computation.