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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions