Multithreading with parallel=True and prange

Numba · Advanced · 13 min

Parallel loops can multiply speed by the number of cores with a one-word change, if the loop is safe.

@njit(parallel=True) enables two things. First, Numba automatically parallelises supported array expressions and reductions. Second, you can replace range with numba.prange to mark a loop whose iterations run in parallel threads. The GIL is not involved: the compiled code runs natively on multiple cores.

Rules for a correct prange loop:

  • Iterations must be independent. Writing to out[i] from iteration i is safe. Writing to a shared element from several iterations is a race condition and gives wrong results.
  • Reductions into a scalar are recognised: total += f(a[i]) inside a prange loop is turned into a per-thread partial sum combined at the end. This works for +=, *= and similar operators on scalars, not for arbitrary updates of array elements.
  • Only the outermost prange in a nest is parallelised; inner ones run serially.

Control and inspect the threading:

  • numba.set_num_threads(n) and get_num_threads() change the thread count at run time, and the NUMBA_NUM_THREADS environment variable sets the maximum.
  • f.parallel_diagnostics(level=1) reports which loops were parallelised and fused.
  • Threading layers (TBB, OpenMP, workqueue) can be selected with NUMBA_THREADING_LAYER.

Parallelism has overhead: for small arrays or cheap loop bodies the single-threaded version is faster. Floating-point reductions may also give slightly different results from the serial sum, because the order of additions changes.

Example

import numpy as np
from numba import njit, prange, get_num_threads

@njit(parallel=True)
def pairwise_min_dist(p):
    n = p.shape[0]
    out = np.empty(n)
    for i in prange(n):                 # rows processed in parallel
        best = np.inf
        for j in range(n):
            if i != j:
                best = min(best, ((p[i] - p[j]) ** 2).sum())
        out[i] = np.sqrt(best)          # each i writes only out[i]: safe
    return out

@njit(parallel=True)
def total(a):
    s = 0.0
    for i in prange(a.size):
        s += a[i]                       # recognised reduction
    return s

pts = np.random.default_rng(0).random((2000, 3))
print(pairwise_min_dist(pts).shape, get_num_threads())  # (2000,) 8  (your core count)
print(total(np.ones(1_000_000)))        # 1000000.0

Remember

  • parallel=True plus prange runs independent iterations on several cores
  • Scalar reductions with += are handled; shared array writes are races
  • Overhead and changed summation order are the trade-offs

Watch out for

  • Updating a shared histogram bin from a prange loop, which silently loses counts.

Check yourself

  1. Inside a prange loop over i, which update is a race condition?

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/parallel.html