Skip to content

Five lessons from running Numba in production

Numba's demo is magical: add @njit, get C speed. Production is where the footnotes live. These five lessons come from long-running compute farms — multi-process Monte Carlo workloads on big machines — and each one cost real debugging time before it became a rule. All five have the same shape: a symptom that looks like your bug, a cause that lives in the runtime, and a boring discipline that makes it never happen again.

1. The on-disk cache can segfault another process

cache=True is the right default for scripts: compile once, reuse the binary across runs. But the cache assumes a stored binary is safe for any process to load, and on some setups — multi-process worker farms, shared filesystems, container layers — a binary compiled by one process intermittently crashes another that loads it. Access violation, no traceback, no pattern.

The failure signature is distinctive, and recognizing it saves weeks:

  • the first run (the one that compiled) works; later runs crash,
  • crashes look random — same code, same data, different outcome,
  • deleting __pycache__ "fixes" it… until the cache repopulates,
  • it happens even on pure, non-parallel kernels.

If that pattern matches, stop debugging your kernel. It's the cache. Disable it globally for that environment and pay the recompile.

One subtlety worth internalizing: cache settings are typically read at decoration time. A code-level "disable cache" call cannot reach functions that were decorated while the library imported — an environment variable set before the first import is the only switch that covers everything. If you build a kill-switch, build that one.

2. The process can crash after your work is done

A rarer cousin: the parallel runtime's threadpool can segfault during teardown, at interpreter shutdown — after every result was computed correctly. The process does all its work, writes its output, and dies with an access violation on the way out.

The consequence is operational, and it changes how you build harnesses: verify runs by their output artifact, not their exit code. A pipeline that treats nonzero exit as failure will re-run (or worse, discard) perfectly good work. Check that the expected file exists and parses; treat the exit code of a Numba-heavy process as advisory.

3. prange charges you per launch, not per element

Every parallel=True region launch synchronizes the whole thread team — a barrier costing roughly the same (~0.4 ms measured, on one machine) whether the region does a million elements or a hundred. A tiny parallel region called millions of times is therefore slower than serial code while pinning every core. That's the trap: CPU monitors show 100% utilization, so it looks like parallelism is working; per-item wall time says otherwise.

Two rules follow. Parallelize coarse, independent, race-free units — one prange over big chunks, never a prange inside a per-item function. And for farms of independent tasks, prefer process-level parallelism: K workers with NUMBA_NUM_THREADS=1 each routinely beats one process with K threads, sidesteps the launch barrier entirely, and (bonus) turns lesson 2's crashes into disposable-worker noise. Long-lived workers amortize compilation; a subprocess per task pays JIT every time.

4. There is no bounds checking, and NaN knows it

Nopython kernels compile without bounds checks. An out-of-range index doesn't raise — it reads or writes somewhere. The nastiest source of out-of-range indices is not arithmetic bugs; it's NaN, because NaN fails every comparison and therefore slides through range filters written the intuitive way:

if x < lo or x > hi:      # NaN: both False -> falls through
    continue
idx = int((x - lo) * scale)   # int(NaN) == INT64_MIN in nopython
counts[idx] += 1              # silent out-of-bounds write

In compiled code int(NaN) is INT64_MIN (the CPU conversion's sentinel), and an unchecked counts[INT64_MIN] lands one element past the buffer — a silent heap write. The same blindness applies to validation: w < 0 accepts NaN weights and corrupts whatever is built from them. The disciplines: write range filters inverted (if not (lo <= x <= hi), which rejects NaN), validate with isfinite rather than sign checks, and run development builds with boundscheck=True so corruption becomes IndexError while you can still afford it.

5. Never answer RecursionError with setrecursionlimit

Two separate traps share this lesson. First, Numba's own compiler can recurse pathologically — certain reduction patterns in parallel loops make the parfor analysis trace until the compiler itself raises RecursionError. The fix is restructuring the kernel (indexed stores into an array instead of mutating a reduction variable), not raising limits. Second, recursion in your own kernels runs on the native stack: Python's recursion limit doesn't govern it, so sys.setrecursionlimit(10**6) trades a clean Python exception for a native stack overflow — a hard crash. Deep traversals in compiled code want explicit stacks over preallocated arrays, with a bounded-depth assert doubling as a cycle detector.

The meta-lesson

None of these are Numba bugs you can wait out; they are properties of running a JIT compiler and a threadpool inside CPython processes at scale. The teams that do this well all converge on the same posture: recognize failure signatures instead of re-deriving them, encode each one as a default (a kill-switch, a harness rule, a code-review pattern), and keep kernels boring.

That posture is why numba-utils exists: its cache page and parallelism page are these lessons in reference form, its diagnostics.check() warns when a function's configuration matches a known failure mode, and its kernels ship with the NaN and validation disciplines above already applied. The library is the residue of the debugging.


Related: Why most Numba benchmarks are wrong · Your coverage tool can't see your fastest code