Overview
Shrink the JAX Delaunay pure_callback to qhull-only and move point location into the JIT program. The callback currently does triangulation + find_simplex (data grid + split points) + numpy assembly on the host — 40–320 ms of host-serial work per likelihood eval that runs sequentially per vmap lane (vmap_method="sequential"), the measured reason the A100 Delaunay cell sits at 106.8 ms/lane vs rectangular's 32.9 ms. A validated PoC (autolens_profiling/scratch/delaunay_speedup/demo_1_callback_shrink.py) shows 4.4× less host-serial work at sub_size 1 and ~30× at sub_size 4, with 99.99% identical mappings and the residual tail falling back to the existing outside-hull semantics. Hard requirement: the likelihood must be numerically unchanged — proven by parity tests in autolens_workspace_test.
Plan
- Replace the JAX-path callback so scipy does only the qhull triangulation, returning the padded simplices plus two small fixed-shape adjacency tables (vertex→incident simplices, vertex→neighbor vertices).
- Reimplement "which triangle contains this point" inside JAX: nearest-vertex seed + full 2-ring signed-barycentric containment test; points contained in none fall back to the nearest-vertex mapping, exactly the existing outside-hull behaviour.
- Move dual areas (scatter-add), split-point generation, and split-point location into JAX; seed split points at their own nearest vertex; same treatment for the Matérn variant.
- The numpy/scipy path is untouched and remains the numerical reference.
- Numpy-only unit tests in PyAutoArray for the new helpers vs
scipy.find_simplex; full likelihood-parity suite in autolens_workspace_test (eager vs jit vs vmap, incl. a near-caustic config).
- Ship as two PRs behind the library-first gate: PyAutoArray first, workspace_test parity suite second. (The Feature Agent's split-into-phases recommendation maps onto this built-in two-step; no extra prompts filed.)
Detailed implementation plan
Affected Repositories
- PyAutoArray (primary)
- autolens_workspace_test (parity tests, phase 2, library-first merge gate)
Branch Survey
| Repository |
Current Branch |
Dirty? |
| ./PyAutoArray |
main (b0f368c) |
clean |
| ./autolens_workspace_test |
fix/visualization-jit-test-mode-output-path (unrelated, open PR) |
clean |
Suggested branch: feature/delaunay-qhull-callback
Worktree root: ~/Code/PyAutoLabs-wt/delaunay-qhull-callback/
Implementation Steps — Phase 1 (PyAutoArray)
All in autoarray/inversion/mesh/interpolator/delaunay.py unless noted. The
return contract of jax_delaunay / jax_delaunay_matern is unchanged
(points, simplices_padded, mappings, split_points, splitted_mappings — same
shapes/dtypes), so DelaunayInterface, _mappings_sizes_weights[_split] and
everything downstream need no edits; only the internals move from host to JAX.
scipy_delaunay_tri_only(points_np) — new minimal host callback: qhull
Delaunay, then build simplices_padded (2N,3 int32, −1 pad; as today),
incident (N, K_INCIDENT) via one stable-argsort grouping pass, and
neighbors (N, K_NEIGHBOR, self included) via one np.unique over packed
directed edges. Pad widths are module constants (start K_INCIDENT=24,
K_NEIGHBOR=20 — measured maxima ~11/~13); assert degree ≤ pad so overflow
fails loudly (no silent guards).
- JAX-side helpers (xp-generic where practical so they unit-test under numpy):
nearest_vertex_from(query, points, chunk=1024) — lax.map-chunked
squared-distance argmin (chunking bounds the (chunk, N) intermediate on
GPU under vmap).
pix_indexes_delaunay_2ring_from(query, seed, points, simplices_padded, incident, neighbors) — candidates = incident[neighbors[seed]]
(the full 2-ring, ~K_N×K_I padded); signed barycentric weights via cross
products with −1-pad masking (−inf min-weight); winner = max min-weight;
inside iff min-weight ≥ −1e-12; else fallback row [seed, −1, −1].
Do not use a greedy walk — the PoC measured walk stalls (98.6%
coverage vs 99.99% for the full 2-ring).
dual_areas_from(points, simplices_padded) — triangle areas → .at[].add
scatter (replaces np.add.at in barycentric_dual_area_from on this path).
- Rewire
jax_delaunay: pure_callback → scipy_delaunay_tri_only only;
then in JAX: locate data grid (seed = nearest vertex), dual areas, split
points via existing split_points_from(..., xp=jnp) with offsets
areas_factor*sqrt(areas), locate split points seeded at their own
nearest vertex (PoC lesson: parent seeding breaks fallback parity with
the KDTree outside-hull rule). Weights for both grids continue to come from
pixel_weights_delaunay_from downstream, unchanged.
jax_delaunay_matern: same tri-only callback + data-grid locate; no
splits/areas (mirrors scipy_delaunay_matern's reduced outputs).
- Numpy path (
scipy_delaunay, scipy_delaunay_matern, the xp is np
branch of InterpolatorDelaunay.delaunay): byte-identical, untouched.
- Unit tests (numpy-only per repo rules) in
test_autoarray/inversion/mesh/: grouped-table construction; 2-ring locate
vs scipy.find_simplex on random + blob/ring adaptive meshes including
outside-hull points (agreement modulo the documented ≤0.04% fallback tail,
which must map to the nearest vertex); pad-overflow assert fires.
Implementation Steps — Phase 2 (autolens_workspace_test, after library PR)
- Parity script(s) in the JAX/parity area (exact placement per repo layout at
start_workspace): Delaunay+Hilbert imaging config — assert
log_evidence parity between eager xp=np FitImaging (still the old
scipy path → the numerical reference), jax.jit round-trip, and
fitness._vmap batch eval; rtol 1e-8 target (fall back to the pinned-value
1e-4 only if fp-drift through Cholesky/log-det justifies it, documented).
Include a near-caustic configuration (high ellipticity + shear) so folded /
skinny source-plane triangles are exercised. Interferometer Delaunay config
if the repo covers one.
- Local validation: run
autolens_profiling/likelihood_runtime/imaging/delaunay.py (CPU) — pinned
log-evidence 29110.92085793 must hold; record before/after timings.
Key Files
PyAutoArray/autoarray/inversion/mesh/interpolator/delaunay.py — the change
PyAutoArray/autoarray/inversion/regularization/regularization_util.py — split_points_from (already xp-generic, reused not modified)
autolens_profiling/scratch/delaunay_speedup/demo_1_callback_shrink.py — validated PoC to port (incl. saved outputs)
autolens_profiling/results/notes/sparse_vs_dense_inversion_path.md — A100 baselines to beat
Risks / trade-offs
- ≤0.04% of points map via the nearest-vertex fallback where scipy found a
distant triangle → bounded by the workspace_test parity rtol; if exceeded,
widen the candidate set to the 3-ring before any other change.
- Pathological meshes could overflow the pad constants → loud assert by design.
- Explicitly out of scope: the fnnls/NNLS solver (separate follow-up),
the numpy path, vmap_method changes, sparse w-tilde.
Autonomy
--auto launch 2026-07-09; effective level supervised (header supervised,
refactor cap safe). Plan written here per the contract; ship sign-off will park
as a batched question on this issue. Merge stays human.
Original Prompt
Click to expand starting prompt
Speed up the JAX Delaunay likelihood by shrinking the scipy pure_callback to qhull-only. Currently InterpolatorDelaunay (PyAutoArray autoarray/inversion/mesh/interpolator/delaunay.py) runs scipy_delaunay through jax.pure_callback with vmap_method=sequential: triangulation + find_simplex on the ~18k oversampled data points + split-point find_simplex + numpy assembly, ~40-320ms of host-serial work per likelihood eval (scales with over-sampling), serialized per vmap lane — the measured reason the A100 Delaunay cell is 106.8ms/lane vs rectangular's 32.9ms. Proven PoC in autolens_profiling/scratch/delaunay_speedup/demo_1_callback_shrink.py: callback returns only simplices_padded + two fixed-shape adjacency tables (vertex->incident simplices, vertex->neighbor vertices, one vectorized numpy pass); point location moves into JAX as nearest-vertex seed + full 2-ring signed-barycentric containment (max-min-weight winner, misses fall back to nearest-vertex mapping = existing outside-hull semantics); dual areas via scatter-add, split points and their location also JAX-side (seed split points at their own nearest vertex, NOT the parent, to match KDTree fallback). PoC parity: 99.99% identical mappings (the 0.01-0.04% tail is the fallback), split grid 100%, matched rows 1e-15; host-serial 4.4x less at sub_size 1, 30x at sub_size 4. Implement fully in PyAutoArray: JAX branch of InterpolatorDelaunay.delaunay only (numpy/scipy path unchanged), including the jax_delaunay_matern variant, fixed global pad widths for the adjacency tables (assert in callback, fail loudly), chunked nearest-vertex distance search (VRAM under vmap). Hard requirement: the likelihood must be numerically unchanged — add parity testing in autolens_workspace_test: jax.jit round-trip + fitness._vmap batch eval comparing new vs old path log-evidence on the Delaunay imaging configs (and interferometer if covered), rtol at the pinned-value level (e.g. 1e-8 on log_evidence, plus exact mapping-matrix comparison modulo the documented fallback tail), exercised near-caustic so folded/skinny triangles are covered. Also keep the eager (xp=np) FitImaging reference assert. Library unit tests stay numpy-only per repo rules: unit-test the new point-location helpers with xp=np against scipy find_simplex on random + adaptive meshes. Do NOT change the fnnls/NNLS solver (separate follow-up task).
Overview
Shrink the JAX Delaunay
pure_callbackto qhull-only and move point location into the JIT program. The callback currently does triangulation +find_simplex(data grid + split points) + numpy assembly on the host — 40–320 ms of host-serial work per likelihood eval that runs sequentially per vmap lane (vmap_method="sequential"), the measured reason the A100 Delaunay cell sits at 106.8 ms/lane vs rectangular's 32.9 ms. A validated PoC (autolens_profiling/scratch/delaunay_speedup/demo_1_callback_shrink.py) shows 4.4× less host-serial work at sub_size 1 and ~30× at sub_size 4, with 99.99% identical mappings and the residual tail falling back to the existing outside-hull semantics. Hard requirement: the likelihood must be numerically unchanged — proven by parity tests inautolens_workspace_test.Plan
scipy.find_simplex; full likelihood-parity suite inautolens_workspace_test(eager vs jit vs vmap, incl. a near-caustic config).Detailed implementation plan
Affected Repositories
Branch Survey
Suggested branch:
feature/delaunay-qhull-callbackWorktree root:
~/Code/PyAutoLabs-wt/delaunay-qhull-callback/Implementation Steps — Phase 1 (PyAutoArray)
All in
autoarray/inversion/mesh/interpolator/delaunay.pyunless noted. Thereturn contract of
jax_delaunay/jax_delaunay_maternis unchanged(points, simplices_padded, mappings, split_points, splitted_mappings — same
shapes/dtypes), so
DelaunayInterface,_mappings_sizes_weights[_split]andeverything downstream need no edits; only the internals move from host to JAX.
scipy_delaunay_tri_only(points_np)— new minimal host callback: qhullDelaunay, then buildsimplices_padded(2N,3 int32, −1 pad; as today),incident(N, K_INCIDENT) via one stable-argsort grouping pass, andneighbors(N, K_NEIGHBOR, self included) via onenp.uniqueover packeddirected edges. Pad widths are module constants (start K_INCIDENT=24,
K_NEIGHBOR=20 — measured maxima ~11/~13);
assertdegree ≤ pad so overflowfails loudly (no silent guards).
nearest_vertex_from(query, points, chunk=1024)—lax.map-chunkedsquared-distance argmin (chunking bounds the (chunk, N) intermediate on
GPU under vmap).
pix_indexes_delaunay_2ring_from(query, seed, points, simplices_padded, incident, neighbors)— candidates =incident[neighbors[seed]](the full 2-ring, ~K_N×K_I padded); signed barycentric weights via cross
products with −1-pad masking (−inf min-weight); winner = max min-weight;
inside iff min-weight ≥ −1e-12; else fallback row
[seed, −1, −1].Do not use a greedy walk — the PoC measured walk stalls (98.6%
coverage vs 99.99% for the full 2-ring).
dual_areas_from(points, simplices_padded)— triangle areas →.at[].addscatter (replaces
np.add.atinbarycentric_dual_area_fromon this path).jax_delaunay: pure_callback →scipy_delaunay_tri_onlyonly;then in JAX: locate data grid (seed = nearest vertex), dual areas, split
points via existing
split_points_from(..., xp=jnp)with offsetsareas_factor*sqrt(areas), locate split points seeded at their ownnearest vertex (PoC lesson: parent seeding breaks fallback parity with
the KDTree outside-hull rule). Weights for both grids continue to come from
pixel_weights_delaunay_fromdownstream, unchanged.jax_delaunay_matern: same tri-only callback + data-grid locate; nosplits/areas (mirrors
scipy_delaunay_matern's reduced outputs).scipy_delaunay,scipy_delaunay_matern, thexp is npbranch of
InterpolatorDelaunay.delaunay): byte-identical, untouched.test_autoarray/inversion/mesh/: grouped-table construction; 2-ring locatevs
scipy.find_simplexon random + blob/ring adaptive meshes includingoutside-hull points (agreement modulo the documented ≤0.04% fallback tail,
which must map to the nearest vertex); pad-overflow assert fires.
Implementation Steps — Phase 2 (autolens_workspace_test, after library PR)
start_workspace): Delaunay+Hilbert imaging config — assert
log_evidenceparity between eagerxp=npFitImaging(still the oldscipy path → the numerical reference),
jax.jitround-trip, andfitness._vmapbatch eval; rtol 1e-8 target (fall back to the pinned-value1e-4 only if fp-drift through Cholesky/log-det justifies it, documented).
Include a near-caustic configuration (high ellipticity + shear) so folded /
skinny source-plane triangles are exercised. Interferometer Delaunay config
if the repo covers one.
autolens_profiling/likelihood_runtime/imaging/delaunay.py(CPU) — pinnedlog-evidence 29110.92085793 must hold; record before/after timings.
Key Files
PyAutoArray/autoarray/inversion/mesh/interpolator/delaunay.py— the changePyAutoArray/autoarray/inversion/regularization/regularization_util.py—split_points_from(already xp-generic, reused not modified)autolens_profiling/scratch/delaunay_speedup/demo_1_callback_shrink.py— validated PoC to port (incl. saved outputs)autolens_profiling/results/notes/sparse_vs_dense_inversion_path.md— A100 baselines to beatRisks / trade-offs
distant triangle → bounded by the workspace_test parity rtol; if exceeded,
widen the candidate set to the 3-ring before any other change.
the numpy path,
vmap_methodchanges, sparse w-tilde.Autonomy
--autolaunch 2026-07-09; effective level supervised (header supervised,refactor cap safe). Plan written here per the contract; ship sign-off will park
as a batched question on this issue. Merge stays human.
Original Prompt
Click to expand starting prompt
Speed up the JAX Delaunay likelihood by shrinking the scipy pure_callback to qhull-only. Currently InterpolatorDelaunay (PyAutoArray autoarray/inversion/mesh/interpolator/delaunay.py) runs scipy_delaunay through jax.pure_callback with vmap_method=sequential: triangulation + find_simplex on the ~18k oversampled data points + split-point find_simplex + numpy assembly, ~40-320ms of host-serial work per likelihood eval (scales with over-sampling), serialized per vmap lane — the measured reason the A100 Delaunay cell is 106.8ms/lane vs rectangular's 32.9ms. Proven PoC in autolens_profiling/scratch/delaunay_speedup/demo_1_callback_shrink.py: callback returns only simplices_padded + two fixed-shape adjacency tables (vertex->incident simplices, vertex->neighbor vertices, one vectorized numpy pass); point location moves into JAX as nearest-vertex seed + full 2-ring signed-barycentric containment (max-min-weight winner, misses fall back to nearest-vertex mapping = existing outside-hull semantics); dual areas via scatter-add, split points and their location also JAX-side (seed split points at their own nearest vertex, NOT the parent, to match KDTree fallback). PoC parity: 99.99% identical mappings (the 0.01-0.04% tail is the fallback), split grid 100%, matched rows 1e-15; host-serial 4.4x less at sub_size 1, 30x at sub_size 4. Implement fully in PyAutoArray: JAX branch of InterpolatorDelaunay.delaunay only (numpy/scipy path unchanged), including the jax_delaunay_matern variant, fixed global pad widths for the adjacency tables (assert in callback, fail loudly), chunked nearest-vertex distance search (VRAM under vmap). Hard requirement: the likelihood must be numerically unchanged — add parity testing in autolens_workspace_test: jax.jit round-trip + fitness._vmap batch eval comparing new vs old path log-evidence on the Delaunay imaging configs (and interferometer if covered), rtol at the pinned-value level (e.g. 1e-8 on log_evidence, plus exact mapping-matrix comparison modulo the documented fallback tail), exercised near-caustic so folded/skinny triangles are covered. Also keep the eager (xp=np) FitImaging reference assert. Library unit tests stay numpy-only per repo rules: unit-test the new point-location helpers with xp=np against scipy find_simplex on random + adaptive meshes. Do NOT change the fnnls/NNLS solver (separate follow-up task).