Parallelism¶
parallel=True + prange is the easiest parallelism in Python — and
the easiest to lose money on. These notes come from production Numba
workloads (large multi-process compute farms); they are the design
constraints for the future numba_utils.parallel module.
The launch barrier: why fine-grained prange loses¶
Every prange region launch synchronizes the whole thread team. That
barrier costs roughly the same regardless of how much work the region
does — measured on one workload at ~0.4 ms whether run with 1, 2 or 8
threads over a region of a few thousand rows.
Consequence: a tiny parallel region called millions of times is slower than serial code, while still pinning every core. The symptom is nasty: CPU monitors show full utilization, but per-item wall time equals the single-thread time. The cores are burning on barriers.
Rule: parallelize coarse, independent, race-free units. A prange
over hero buckets where each iteration writes its own output slot scales
near-linearly. A prange inside an evaluator called per item does not.
Process-level parallelism often beats thread-level¶
For farms of independent work items, the winning layout is usually:
NUMBA_NUM_THREADS=1per worker process,- K single-threaded workers running in parallel at the process level,
- each worker long-lived (JIT is paid once per process — see performance.md).
This also sidesteps a real failure mode: on some setups, repeatedly launching parallel regions in one process crashes the threadpool. If a pass must re-launch a parallel kernel many times, run each pass in its own (retryable) subprocess.
A related upstream Numba bug: the threadpool teardown at interpreter
shutdown can segfault (0xC0000005 on Windows) after all work has
completed correctly. The crash lives entirely inside Numba's threading
layer while the process exits; user code already finished and its output
is valid. Two consequences:
- Verify runs by their output artifact, not their exit code. A harness that treats a nonzero return code as failure will flag successful runs as broken. Check that the expected file was written or the expected output was produced instead.
- Mitigations are the same as above:
NUMBA_NUM_THREADS=1per worker, disposable worker processes (where an exit-time crash is harmless), and optionally a differentNUMBA_THREADING_LAYER— stability varies per machine, so treat a layer switch as an experiment, not a fix.
Structural rules¶
- Co-locate a parallel kernel with its
prangedriver in the same module, and call it from Python. Cross-modulenjit -> njitcalls into a parallel region have produced segfaults in the wild. - At small per-call N, prefer the serial kernel and make
prangeopt-in for genuinely large N. Thread-spawn overhead dominates tiny workloads. - Don't mutate a reduction variable to unrank indices inside
prange— the parfor pass can recurse tracing reductions until the compiler itself raisesRecursionError. Enumerate into an array with indexed stores instead. And never raisesys.setrecursionlimitto silence a NumbaRecursionError: Numba doesn't honor it, and deep native recursion is a crash, not a catchable exception.
Verify numerics¶
A parallel reduction reorders floating-point operations. Before trusting a parallel kernel, diff its output against the serial version on real data — same-machine serial vs multi-thread should be within (ideally at) zero difference for order-independent algorithms, and you want to KNOW the difference for reductions rather than assume it.
diagnostics.check(fn) flags parallel=True functions with a summary
of these caveats.
The numba_utils.parallel module¶
These rules are embodied as complete operations, not prange wrappers:
parallel_sum, parallel_reduce (per-index kernel decorator),
parallel_histogram (per-thread private rows, cache-line padded, merged
serially — bit-exact with serial), parallel_prefix_sum (two-phase
blocked scan) and parallel_topk (per-chunk heaps, merge). Every one
falls back to the serial path below SERIAL_THRESHOLD, where the launch
barrier would dominate.