Skip to content

refactor: qhull-only Delaunay callback, JAX point location #367

Description

@Jammy2211

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.

  1. 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).
  2. 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).
  3. 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.
  4. jax_delaunay_matern: same tri-only callback + data-grid locate; no
    splits/areas (mirrors scipy_delaunay_matern's reduced outputs).
  5. Numpy path (scipy_delaunay, scipy_delaunay_matern, the xp is np
    branch of InterpolatorDelaunay.delaunay): byte-identical, untouched.
  6. 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)

  1. 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.
  2. 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.pysplit_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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions