Writing fast loops over arrays

Numba · Essentials · 11 min

Explicit loops are Numba's strength, and a few habits decide whether they run at C speed.

With Numba, the loop you would avoid in plain Python becomes the fastest option. A typical compiled kernel looks like C written in Python syntax: preallocate the output with np.empty, loop over indices, and write results element by element.

Habits that keep loops fast:

  • Preallocate arrays once instead of appending to lists.
  • Loop in memory order. For a C-ordered 2D array, make the last index the inner loop: for i in range(n): for j in range(m): a[i, j]. Striding across rows in the inner loop wastes cache.
  • Keep types stable. Initialise accumulators with the right type (0.0 for floats). A variable that is an int in one branch and a float in another forces type unification and can block optimisation.
  • Avoid temporary arrays inside hot loops. a[i:i+3].sum() inside a loop allocates a view and calls a function; a short inner loop is often faster.
  • Fuse passes. Compute several statistics in one loop instead of calling mean, then std, then max, each of which reads the whole array.

Loops with data dependencies are where Numba shines over NumPy: running sums with resets, state machines, recurrences like y[i] = a * y[i-1] + x[i], or algorithms with early exit. These are either impossible to vectorize or need several temporary arrays in NumPy.

Arrays passed in are not copied: writing to them inside the function modifies the caller's array, just as in Python.

Example

import numpy as np
from numba import njit

@njit
def ewma(x, alpha):
    # y[i] depends on y[i-1]: hard to vectorize, easy to loop
    y = np.empty_like(x)
    y[0] = x[0]
    for i in range(1, x.size):
        y[i] = alpha * x[i] + (1 - alpha) * y[i - 1]
    return y

@njit
def row_sums(a):
    out = np.zeros(a.shape[0])
    for i in range(a.shape[0]):        # outer: rows
        for j in range(a.shape[1]):    # inner: contiguous columns
            out[i] += a[i, j]
    return out

print(ewma(np.array([1.0, 2.0, 3.0, 4.0]), 0.5))  # [1.    1.5   2.25  3.125]
print(row_sums(np.arange(6.0).reshape(2, 3)))     # [ 3. 12.]

Remember

  • Preallocate outputs and write by index
  • Make the innermost loop walk contiguous memory
  • Recurrences and early exits are where loops beat vectorized NumPy

Watch out for

  • Iterating column-first over a C-ordered array in the inner loop.

Check yourself

  1. For a C-ordered array a of shape (n, m), which loop order is faster in Numba?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: numba.readthedocs.io/en/stable/user/performance-tips.html