Repository navigation
Description
Overview
PyAutoArray's two numba parallel=True kernels (the Sibson/DelaunayNN interpolator and the parallel interferometer direct_conv curvature kernel) run on numba's default threading layer, which is GNU OpenMP when TBB is absent. PyAutoFit warms up the likelihood in the parent before forking search workers, so the parent starts the OpenMP pool and every forked Nautilus worker is terminated (fork() called from a process already using GNU OpenMP). Every NumPy-path DelaunayNN fit with more than one core dies this way; it blocked RAL job 414150 (likelihood-lifecycle phase 4, delaunay_nn CPU validation leg).
Plan
- Add one small fork-safety guard to
autoarray/numba_util.py, modelled on the existing finufft guard inoperators/nufft_cpu.py. - On the first parallel launch in a process, if the user has not chosen a numba threading layer, select a fork-safe one: TBB when installed, otherwise numba's
workqueue. A user'sNUMBA_THREADING_LAYERis always respected. - In a process forked after that first launch (a search worker), pin numba to one thread, so 8 workers don't each start one thread per node core.
- Call the guard immediately before both
parallel=Truekernels launch. - Tests: a subprocess test that runs the Sibson kernel in a parent and then in a fork-context pool (today this raises TimeoutError), plus unit tests of the guard's layer and thread choices. The existing parity tests must stay green.
- Validate on RAL by resubmitting the delaunay_nn validation leg from a checkout carrying the fix.
Tier: glance — merge mode: auto-merge on green if the Witness passes, post-merge summary
Detailed implementation plan
Affected Repositories
- PyAutoArray (primary)
Branch Survey
| Repository | Current Branch | Dirty? |
|---|---|---|
| ./PyAutoArray | main | clean |
PyAutoArray is also claimed by magnification-maps-wiring (PyAutoLens#818, uncommitted edits to autoarray/plot/array.py only). On 2026-11-05 the human ruled to run this fix in parallel (no overlapping files).
Suggested branch: feature/numba-fork-safe-threading
Work Classification: Library
Worktree root: ~/Code/PyAutoLabs-wt/numba-fork-safe-threading/
Implementation Steps
autoarray/numba_util.py: addprepare_parallel_launch()plus module state_parallel_launch_pidand_threads_pinned_pid.- First call in a process lineage: if
NUMBA_THREADING_LAYERis not in the environment andnumba.config.THREADING_LAYER == "default", setnumba.config.THREADING_LAYERto"tbb"ifimportlib.util.find_spec("tbb")finds it, else"workqueue". This must run before numba's first parallel launch, because the layer is fixed at that launch. Recordos.getpid(). - Later calls in a different pid (a forked child; module state is inherited): call
numba.set_num_threads(1)once per child, unless the user setNUMBA_NUM_THREADS. - The docstring explains omppool's after-fork termination, the oversubscription problem, and workqueue's caveat (not safe for concurrent launches from several Python threads in one process; the search pools use processes).
- First call in a process lineage: if
autoarray/inversion/mesh/interpolator/sibson.py_sibson_mappings_weights_numba: callnumba_util.prepare_parallel_launch()beforekernel(...).autoarray/inversion/inversion/interferometer_numba/sparse.py: call it beforedirect_conv_parallel_kernel()(*args). Do the same intest__parallel_kernel_equals_serial_kernel, or let the kernel wrapper call it.- Tests in
test_autoarray/numba/test_numba_fork_safety.py(or next to the Sibson tests):- (a) Run a
python -Isubprocess: Sibson tables on a small random mesh, call in the parent, thenmp.get_context("fork").Pool(2)calls it in children. Assert the children's outputs equal the parent's within a timeout, and that the children reportnumba.get_num_threads() == 1. A subprocess is used because the pytest process may already have fixed its layer. Skip when fork is unavailable or numba is missing. - (b) Unit tests: the layer choice respects the env var and an explicit config value, and a pid change triggers pinning (monkeypatch
os.getpid). - (c) A control: the same subprocess with
NUMBA_THREADING_LAYER=ompreproduces the failure, so the test is not vacuous (skip if omp is unavailable).
- (a) Run a
- Run the PyAutoArray unit tests (Sibson parity, interferometer numba), then smoke the delaunay_nn path.
Key Files
autoarray/numba_util.py: the new guardautoarray/inversion/mesh/interpolator/sibson.py: Sibson numba dispatch (_sibson_mappings_weights_numba)autoarray/inversion/mesh/interpolator/_sibson_numba.py: theprangekernel (unchanged)autoarray/inversion/inversion/interferometer_numba/sparse.py: the parallel direct_conv call siteautoarray/operators/nufft_cpu.py: prior art (_openmp_pid)
Out of scope (noted)
- The parent's numba thread count defaults to the node's CPU count (252 on RAL), not the job's allocation. Capping it would change profiled timings, so it is left for a separate decision.
Original Prompt
Click to expand starting prompt
numba parallel=True kernels (Sibson, direct_conv) pick the GNU OpenMP threading layer, which kills every Nautilus worker forked after the likelihood warm-up
Type: bug
Target: autoarray
Repos:
- PyAutoArray
Themes: - source-reconstruction
- performance
Difficulty: small
Autonomy: supervised
Priority: high
Status: formalised
Consequence: glance
Witness: A PyAutoArray unit test runs the Sibson numba kernel (and the parallel direct_conv kernel) in the parent, then calls it from a fork-context multiprocessing pool, and gets the parent's result back within a timeout (today the default threading layer kills the children: TimeoutError); forked children run the kernel on one numba thread; and the RAL rerun of autolens_inference imaging/delaunay_nn/hst numba_cpu_sparse (job 414150's submit) gets past source_pix[1].
Review-minutes: 3
Unattended: ready
Filed: 2026-11-05
Updated: 2026-11-05
Finding
2026-11-05, likelihood-lifecycle phase 4 CPU batch, RAL job 414150 (both seeds), autolens_inference
imaging/delaunay_nn/hst numba_cpu_sparse_fp64, pin 2026.10.25.1, 8 cores. At SLaM stage
source_pix[1], PyAutoFit runs "the likelihood once before forking workers", then Nautilus forks its
pool. The children print Terminating: fork() called from a process already using GNU OpenMP, this is unsafe., the parent's result.get raises multiprocessing.context.TimeoutError, and the runner fails
the job (exit 1, 43 s). Log: /mnt/ral/jnightin/inference/autolens_inference/hpc/batch_cpu/error/error.414150_0.err.
The plain Delaunay numba leg (414149) is fine at the same stage. Its interpolator has no prange.
Mechanism
autoarray/inversion/mesh/interpolator/_sibson_numba.py is @numba.njit(parallel=True) with prange
over queries (likewise direct_conv_parallel_kernel in
inversion/interferometer_numba/inversion_interferometer_numba_util.py). With no TBB installed, numba's
default threading layer is omp, built on GNU OpenMP. Its omppool after-fork handler terminates any
child forked from a parent that has started the pool. The parent warm-up starts it, so every forked
worker dies.
Laptop repro (numba 0.68.1, Python 3.13): a prange kernel called in the parent, then
mp.get_context("fork").Pool(2).map_async(...). With the default and omp layers this gives
TimeoutError. With forksafe (falls back to workqueue without TBB) and workqueue, the children return
the correct results.
Second hazard: in a forked child numba starts NUMBA_NUM_THREADS threads, which defaults to the node's
CPU count (252 on RAL), not the job's allocation. With 8 Nautilus workers that heavily oversubscribes the
node.
Fix direction (library, never the workspace scripts)
Mirror the finufft guard in autoarray/operators/nufft_cpu.py (_openmp_pid). One shared helper used by
both parallel kernels:
- Before the first parallel launch, if the user has not chosen a layer (
NUMBA_THREADING_LAYERunset /
numba.config.THREADING_LAYER == "default"), select a fork-safe layer (tbbif importable, else
workqueue). - Record the pid of the first launch. In any other (forked) process, call
numba.set_num_threads(1)
before launching.
Results must stay bit-identical: the parallel loops have no reductions, and the parity tests pin this.
Why it matters
Every NumPy-path DelaunayNN fit with number_of_cores > 1 dies on any machine without TBB, as does any
parallel direct_conv interferometer fit. It blocks the phase-4 delaunay_nn CPU validation leg (Pulse
likelihood-lifecycle campaign). Phase-2 profiled DelaunayNN per-call timings were single-process omp
runs, so an ingest note should record the layer the validation legs ran under.
Activity
Jammy2211 commentedon Nov 5, 2026
Design change (human, 2026-11-05): numba parallel threads must never run inside Python multiprocessing workers, so there is no nested parallelism at all. This replaces the threading-layer switch and
set_num_threads(1)from the plan above.numba_utilreturns False inside any multiprocessing child (multiprocessing.parent_process() is not None, which covers fork, spawn and forkserver) or a forked pid. Otherwise it returns True.parallel=Falsebuild of the same body. Search workers use it; a single-process main process keeps the parallel kernel.omp, fork and spawn children calling the serial build all return correct results and never initialise a threading layer.NUMBA_THREADING_LAYER=omp; a control with the predicate forced True that reproduces the termination; serial == parallel parity; unit tests of the predicate.🤖 Generated with Claude Code
Merge pull request #677 from PyAutoLabs/feature/numba-fork-safe-threa…
Jammy2211 commentedon Nov 5, 2026
Shipped: PR #677 merged as
e2b05f00(pending-release).prangekernels run serially in multiprocessing search workers (numba_util.parallel_kernel_allowed()). Sibson gets aparallel=Falsebuild of the same body, anddirect_convtakes its serial or symmetric route in workers. A single-process main process is unchanged.test_autoarray2295 passed, 4 xfailed. The new fork-safety test fails before the fix and passes after. CI green: 3.12, 3.13, nojax.Terminating: fork()…, TimeoutError) and completes on the fix.d24c2477). Both seeds got past the parent warm-up and are sampling source_pix[1], with zero fork terminations.🤖 Generated with Claude Code