You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
InterferometerSparseOperator (autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py:857) imports jax.numpy inside every method body and has no xp argument. A CPU user without JAX still runs the whole interferometer curvature build through JAX — an unwanted dependency, and slower: a NumPy/scipy rfft2 convolution with a scipy.sparse projection beats the JAX-CPU route at every geometry measured.
This task gives the operator an xp-aware application path. The application backend follows the inversion's xp (what the fit passes), not the dataset's use_jax kwarg — which after PyAutoArray#541 only picks a brute-force builder and is otherwise irrelevant. That is the repo's own convention (transformer.image_from(xp=)) and it removes the "surprising split" the prompt names without changing any dataset API. The JAX branch stays exactly as it is.
Phase 3 of the retired numba-interferometer-revisit epic. It is also a prerequisite for the numba direct_conv follow-up: change 1 below keeps the raw real-space preload that the numba kernel indexes.
Measured (from the prompt)
geometry
mesh
JAX-CPU (fft2)
NumPy (rfft2)
saving
sma
Delaunay S=1500
1.5239 s
1.2660 s
1.20x
alma
Delaunay S=1500
5.5156 s
4.3951 s
1.26x
alma_high
Delaunay S=1500
12.9655 s
11.0346 s
1.18x
sma
rect S=784
0.8510 s
0.7361 s
1.16x
alma
rect S=784
2.9317 s
2.6557 s
1.10x
alma_high
rect S=784
6.2817 s
4.8414 s
1.30x
These numbers predate the rfft2 change (PyAutoArray#540, merged 2026-09-08). The JAX-CPU column is now faster than shown, so the remaining gap is smaller than the table's saving column — measure against the post-rfft2 baseline, not against these.
Merged today, same epic: PyAutoArray#540 (rfft2 in apply_operator), PyAutoArray#541 (NUFFT type-1 preload)
Follow-on: feat: numba CPU interferometer curvature path — direct_conv, geometry-gated is stacked on this branch.
Plan
Give InterferometerSparseOperator a NumPy/scipy application path: every public method gains xp=np and branches to a scipy.fft / scipy.sparse body when xp is not JAX; the existing JAX bodies are untouched.
Keep the raw (2y, 2x) real-space preload on the operator instead of discarding it after the FFT, and make the JAX-typed fields (Khat, col_offsets) lazily cached so constructing the operator under xp=np never imports JAX.
Have InversionInterferometerSparse pass self._xp to every operator call, and delete the if self._xp is np: np.array(...) workaround that only existed because the operator always returned JAX arrays.
One docstring sentence on dataset.apply_sparse_operator recording that the application backend follows the inversion's xp; no dataset signature change.
Pin the NumPy branch against the JAX branch at rtol=1e-10, atol=1e-10*max|ref| for all six methods, end-to-end through the inversion, and add a no-JAX guard test that runs on the unittest-nojax CI leg.
New field nufft_precision_operator: np.ndarray — the raw (2y, 2x) float64 preload, kept rather than discarded after Khat = rfft2(preload) (task 4 indexes it; it is small).
Khat (JAX rfft2) and a new khat_np (scipy.fft.rfft2) become lazily cached (functools.cached_property works on a frozen dataclass; or store khat_np eagerly since scipy is a hard dep and compute the JAX Khat lazily) so constructing the operator under xp=np never imports JAX. Keep w_dtype and col_offsets for the JAX branch; col_offsets becomes lazy too (it is a jnp.arange).
Verify the pytree registration (simulator.py:56, no_flatten) and the existing dataset/simulator tests still pass — the operator is static aux data.
Every public method gains xp=np — apply_operator, curvature_matrix_diag_from, curvature_matrix_off_diag_from, operated_matrix_slim_from, curvature_matrix_off_diag_func_list_from, curvature_matrix_func_list_from — branching if xp.__name__.startswith("jax") to the existing bodies unchanged, else to the NumPy bodies:
curvature_matrix_diag_from: A = scipy.sparse.csc_matrix((vals, (rows, cols)), shape=(M, S)) (duplicates sum, as required), AT = A.T.tocsr(); for column blocks of batch_size: F = A[:, s:e].toarray(), G = apply_operator(F), C[:, s:e] = AT @ G; return 0.5 * (C + C.T) — the JAX branch symmetrises, so the NumPy branch must too for an exact pin. This is the prototype's curvature_fft_numpy with the symmetrisation added.
operated_matrix_slim_from: grid = zeros((M, n)); grid[extent_index] = matrix_slim (a set, not an add — indices are unique); apply; gather [extent_index].
the two func-list methods: the same scatter-set / apply / A.T @ pattern.
No batching masks or padding are needed on the NumPy side (they exist only for static shapes under lax.fori_loop).
2. sparse.py::InversionInterferometerSparse
Pass xp=self._xp to every operator call.
Delete the if self._xp is np: curvature_matrix = np.array(...) workaround at :171-175 (its comment says why it existed). data_vector unchanged.
3. dataset.py
No signature change. One docstring sentence on apply_sparse_operator saying the application backend follows the inversion's xp.
For each of the six methods: NumPy branch vs JAX branch at rtol=1e-10, atol=1e-10*max|ref| on the 7x7 / K=5 fixture and the 12x12 / K=64 seeded case added by PyAutoArray#540 (_dataset_from), with S larger than batch_size in at least one case (pass batch_size=4) so the block loop is exercised, plus a Delaunay mapper case with duplicate COO entries (the existing test_interferometer.py Delaunay fixture).
InversionInterferometerSparse(xp=np) end-to-end: curvature_matrix, data_vector, reconstruction, log_evidence equal the xp=jnp inversion at the same pin.
A no-JAX guard: with jax made unimportable (monkeypatch.setitem(sys.modules, "jax", None) and "jax.numpy"), constructing the operator and running the NumPy branch works. These tests must not carry importorskip("jax"), so the unittest-nojax CI leg runs them.
Keep every existing test and tolerance.
Key Files
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py — InterferometerSparseOperator (from :857); the six methods and the Khat / col_offsets fields.
autoarray/inversion/inversion/interferometer/sparse.py — InversionInterferometerSparse; already resolves self._xp is np cleanly, triplets from mapper_util.sparse_triplets_from are pure NumPy; the :171-175 workaround.
autolens_profiling/scripts/misc/numba_interferometer/kernels.py::curvature_fft_numpy — the reference implementation to port (scipy rfft2/irfft2, scipy.sparse CSC/CSR projection, batch 128); pinned to the numba reference at rtol=1e-10 in that pack's test_parity.py.
test_autoarray/inversion/inversion/interferometer/test_inversion_interferometer_util.py — exact-pin idiom at :345.
Six-method parity NumPy vs JAX at the exact pin; end-to-end inversion parity.
No-JAX guard passes with jax blocked; the unittest-nojax CI leg green.
No existing test or tolerance changed.
Operator construction under xp=np imports no JAX (assert "jax" not in sys.modules in a subprocess test).
pytest test_autoarray/inversion test_autoarray/dataset test_autoarray/operators -q green; black clean.
Original Prompt
Click to expand starting prompt
InterferometerSparseOperator has no NumPy path — a CPU user is forced through JAX
Type: feature
Target: autoarray
Repos:
PyAutoArray
Themes:
interferometer
numpy-cpu
likelihood-profiling
Difficulty: medium
Autonomy: supervised
Priority: medium
Epic: numba-interferometer-revisit
Filed: 2026-09-07
Follow-up from autolens_profiling#226 (phase 2 of numba-interferometer-revisit);
numbers in autolens_profiling/results/notes/numba_interferometer_verdict.md.
What
InterferometerSparseOperator.apply_operator, curvature_matrix_diag_from and curvature_matrix_off_diag_from
(autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py) all import jax.numpy inside the method body. There is no xp=np branch: a user without a GPU
still runs the interferometer curvature through JAX, and InversionInterferometerSparse
inherits that whatever xp the fit was built with.
That is not only an unwanted dependency — it is slower. On identical inputs, a NumPy/scipy rfft2 convolution with a scipy.sparse projection (the NumPy-stack counterpart of the JAX
route's segment_sum) beats the library's JAX-CPU route at every geometry measured:
geometry
mesh
JAX-CPU (fft2)
NumPy (rfft2)
saving
sma
Delaunay S=1500
1.5239 s
1.2660 s
1.20×
alma
Delaunay S=1500
5.5156 s
4.3951 s
1.26×
alma_high
Delaunay S=1500
12.9655 s
11.0346 s
1.18×
sma
rect S=784
0.8510 s
0.7361 s
1.16×
alma
rect S=784
2.9317 s
2.6557 s
1.10×
alma_high
rect S=784
6.2817 s
4.8414 s
1.30×
(Some of that gap is the rfft2 change filed separately as interferometer_apply_operator_rfft2.md, which shipped on 2026-09-08 — PyAutoArray#540,
record complete/2026/09/interferometer-apply-operator-rfft2.md. The real-FFT-first
sequencing constraint is therefore already satisfied; measure this one against the
post-rfft2 baseline, not the numbers in the table above.)
Scope
An xp-aware application path so xp=np uses scipy.fft and scipy.sparse and never
imports JAX; the JAX branch stays exactly as it is.
The reference implementation to port is autolens_profiling/scripts/misc/numba_interferometer/kernels.py::curvature_fft_numpy,
which is pinned to the numba reference kernel at rtol=1e-10 in that pack's test_parity.py.
Parity gate: the NumPy and JAX branches must agree at rtol=1e-10 on F with atol
scaled by max|F|.
Worth checking at the same time whether apply_sparse_operator(use_jax=False) should
imply the NumPy application path (today it only affects how the preload is built, not
how the operator is applied, which is a surprising split).
Overview
InterferometerSparseOperator(autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py:857) importsjax.numpyinside every method body and has noxpargument. A CPU user without JAX still runs the whole interferometer curvature build through JAX — an unwanted dependency, and slower: a NumPy/scipyrfft2convolution with ascipy.sparseprojection beats the JAX-CPU route at every geometry measured.This task gives the operator an
xp-aware application path. The application backend follows the inversion'sxp(what the fit passes), not the dataset'suse_jaxkwarg — which after PyAutoArray#541 only picks a brute-force builder and is otherwise irrelevant. That is the repo's own convention (transformer.image_from(xp=)) and it removes the "surprising split" the prompt names without changing any dataset API. The JAX branch stays exactly as it is.Phase 3 of the retired
numba-interferometer-revisitepic. It is also a prerequisite for the numbadirect_convfollow-up: change 1 below keeps the raw real-space preload that the numba kernel indexes.Measured (from the prompt)
fft2)rfft2)These numbers predate the
rfft2change (PyAutoArray#540, merged 2026-09-08). The JAX-CPU column is now faster than shown, so the remaining gap is smaller than the table'ssavingcolumn — measure against the post-rfft2baseline, not against these.Links
PyAutoMind/complete/archive/epics/numba_interferometer_likelihood_revisit.mdautolens_profiling/results/notes/numba_interferometer_verdict.md)rfft2inapply_operator), PyAutoArray#541 (NUFFT type-1 preload)feat: numba CPU interferometer curvature path — direct_conv, geometry-gatedis stacked on this branch.Plan
InterferometerSparseOperatora NumPy/scipy application path: every public method gainsxp=npand branches to ascipy.fft/scipy.sparsebody whenxpis not JAX; the existing JAX bodies are untouched.(2y, 2x)real-space preload on the operator instead of discarding it after the FFT, and make the JAX-typed fields (Khat,col_offsets) lazily cached so constructing the operator underxp=npnever imports JAX.InversionInterferometerSparsepassself._xpto every operator call, and delete theif self._xp is np: np.array(...)workaround that only existed because the operator always returned JAX arrays.dataset.apply_sparse_operatorrecording that the application backend follows the inversion'sxp; no dataset signature change.rtol=1e-10, atol=1e-10*max|ref|for all six methods, end-to-end through the inversion, and add a no-JAX guard test that runs on theunittest-nojaxCI leg.Detailed implementation plan
Work Classification
Library
Affected Repositories
Branch Survey
9bd76799)Suggested branch:
feature/interferometer-sparse-operator-numpy-cpu-pathWorktree root:
~/Code/PyAutoLabs-wt/interferometer-sparse-operator-numpy-cpu-path/Implementation Steps
All changes are in
autoarray/inversion/inversion/interferometer/.1.
inversion_interferometer_util.py::InterferometerSparseOperatornufft_precision_operator: np.ndarray— the raw(2y, 2x)float64 preload, kept rather than discarded afterKhat = rfft2(preload)(task 4 indexes it; it is small).Khat(JAXrfft2) and a newkhat_np(scipy.fft.rfft2) become lazily cached (functools.cached_propertyworks on a frozen dataclass; or storekhat_npeagerly since scipy is a hard dep and compute the JAXKhatlazily) so constructing the operator underxp=npnever imports JAX. Keepw_dtypeandcol_offsetsfor the JAX branch;col_offsetsbecomes lazy too (it is ajnp.arange).simulator.py:56,no_flatten) and the existing dataset/simulator tests still pass — the operator is static aux data.xp=np—apply_operator,curvature_matrix_diag_from,curvature_matrix_off_diag_from,operated_matrix_slim_from,curvature_matrix_off_diag_func_list_from,curvature_matrix_func_list_from— branchingif xp.__name__.startswith("jax")to the existing bodies unchanged, else to the NumPy bodies:apply_operator:scipy.fft.rfft2(F_pad) * khat_np->irfft2(s=(2y, 2x))-> crop.curvature_matrix_diag_from:A = scipy.sparse.csc_matrix((vals, (rows, cols)), shape=(M, S))(duplicates sum, as required),AT = A.T.tocsr(); for column blocks ofbatch_size:F = A[:, s:e].toarray(),G = apply_operator(F),C[:, s:e] = AT @ G; return0.5 * (C + C.T)— the JAX branch symmetrises, so the NumPy branch must too for an exact pin. This is the prototype'scurvature_fft_numpywith the symmetrisation added.curvature_matrix_off_diag_from:A0,A1; blocks overA1;C01[:, s:e] = A0.T @ apply(A1[:, s:e]).operated_matrix_slim_from:grid = zeros((M, n)); grid[extent_index] = matrix_slim(a set, not an add — indices are unique);apply; gather[extent_index].A.T @pattern.lax.fori_loop).2.
sparse.py::InversionInterferometerSparsexp=self._xpto every operator call.if self._xp is np: curvature_matrix = np.array(...)workaround at:171-175(its comment says why it existed).data_vectorunchanged.3.
dataset.pyapply_sparse_operatorsaying the application backend follows the inversion'sxp.4. Tests (
test_inversion_interferometer_util.py,test_interferometer.py)rtol=1e-10, atol=1e-10*max|ref|on the 7x7 / K=5 fixture and the 12x12 / K=64 seeded case added by PyAutoArray#540 (_dataset_from), withSlarger thanbatch_sizein at least one case (passbatch_size=4) so the block loop is exercised, plus a Delaunay mapper case with duplicate COO entries (the existingtest_interferometer.pyDelaunay fixture).InversionInterferometerSparse(xp=np)end-to-end:curvature_matrix,data_vector,reconstruction,log_evidenceequal thexp=jnpinversion at the same pin.jaxmade unimportable (monkeypatch.setitem(sys.modules, "jax", None)and"jax.numpy"), constructing the operator and running the NumPy branch works. These tests must not carryimportorskip("jax"), so theunittest-nojaxCI leg runs them.Key Files
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py—InterferometerSparseOperator(from:857); the six methods and theKhat/col_offsetsfields.autoarray/inversion/inversion/interferometer/sparse.py—InversionInterferometerSparse; already resolvesself._xp is npcleanly, triplets frommapper_util.sparse_triplets_fromare pure NumPy; the:171-175workaround.autoarray/inversion/inversion/interferometer/dataset.py—apply_sparse_operatordocstring.autolens_profiling/scripts/misc/numba_interferometer/kernels.py::curvature_fft_numpy— the reference implementation to port (scipyrfft2/irfft2,scipy.sparseCSC/CSR projection, batch 128); pinned to the numba reference atrtol=1e-10in that pack'stest_parity.py.test_autoarray/inversion/inversion/interferometer/test_inversion_interferometer_util.py— exact-pin idiom at:345.test_autoarray/inversion/inversion/interferometer/test_interferometer.py— Delaunay / rectangular DFT fixtures.Verification
jaxblocked; theunittest-nojaxCI leg green.xp=npimports no JAX (assert"jax" not in sys.modulesin a subprocess test).pytest test_autoarray/inversion test_autoarray/dataset test_autoarray/operators -qgreen;blackclean.Original Prompt
Click to expand starting prompt
InterferometerSparseOperatorhas no NumPy path — a CPU user is forced through JAXType: feature
Target: autoarray
Repos:
Themes:
Difficulty: medium
Autonomy: supervised
Priority: medium
Epic: numba-interferometer-revisit
Filed: 2026-09-07
Follow-up from
autolens_profiling#226(phase 2 ofnumba-interferometer-revisit);numbers in
autolens_profiling/results/notes/numba_interferometer_verdict.md.What
InterferometerSparseOperator.apply_operator,curvature_matrix_diag_fromandcurvature_matrix_off_diag_from(
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py) allimport jax.numpyinside the method body. There is noxp=npbranch: a user without a GPUstill runs the interferometer curvature through JAX, and
InversionInterferometerSparseinherits that whatever
xpthe fit was built with.That is not only an unwanted dependency — it is slower. On identical inputs, a NumPy/scipy
rfft2convolution with ascipy.sparseprojection (the NumPy-stack counterpart of the JAXroute's
segment_sum) beats the library's JAX-CPU route at every geometry measured:fft2)rfft2)(Some of that gap is the
rfft2change filed separately asinterferometer_apply_operator_rfft2.md, which shipped on 2026-09-08 — PyAutoArray#540,record
complete/2026/09/interferometer-apply-operator-rfft2.md. The real-FFT-firstsequencing constraint is therefore already satisfied; measure this one against the
post-
rfft2baseline, not the numbers in the table above.)Scope
xp-aware application path soxp=npusesscipy.fftandscipy.sparseand neverimports JAX; the JAX branch stays exactly as it is.
autolens_profiling/scripts/misc/numba_interferometer/kernels.py::curvature_fft_numpy,which is pinned to the numba reference kernel at
rtol=1e-10in that pack'stest_parity.py.rtol=1e-10onFwithatolscaled by
max|F|.apply_sparse_operator(use_jax=False)shouldimply the NumPy application path (today it only affects how the preload is built, not
how the operator is applied, which is a surprising split).