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.apply_operator zero-pads a real batch to (2y, 2x) and applies the operator with the complex jnp.fft.fft2 / jnp.fft.ifft2 pair, taking the real part at the end; Khat = jnp.fft.fft2(nufft_precision_operator) is likewise the complex transform of a real array. Because both inputs are real, the real-transform pair rfft2 / irfft2 computes the same result exactly — not approximately — with roughly half the spectrum and half the butterflies. Measured 1.27–1.61× on the operator apply across every geometry and mesh tested, and the NumPy rfft2 route also beats the library's own JAX-CPU route by 1.10–1.30× at every cell.
Follow-up from autolens_profiling#226 (phase 2 of the retired epic numba-interferometer-revisit, ledger PyAutoMind/complete/archive/epics/numba_interferometer_likelihood_revisit.md). Numbers: autolens_profiling/results/notes/numba_interferometer_verdict.md and results/breakdown/interferometer/bakeoff_v2026.8.17.1.json.
Measured saving
Single thread, i9-10885H, autoarray 2026.8.17.1; same synthetic inputs, same batch size (128), scipy stack, so the comparison isolates the transform:
geometry (extent)
mesh
complex fft2
real rfft2
saving
sma (70²)
Delaunay S=1500
1.7479 s
1.2660 s
1.38×
alma (140²)
Delaunay S=1500
6.5003 s
4.3951 s
1.48×
alma_high (280²)
Delaunay S=1500
17.7362 s
11.0346 s
1.61×
sma (70²)
rect S=784
1.0124 s
0.7361 s
1.38×
alma (140²)
rect S=784
3.3786 s
2.6557 s
1.27×
alma_high (280²)
rect S=784
6.9360 s
4.8414 s
1.43×
Plan
Cache Khat as the real transform of the preload: jnp.fft.rfft2(nufft_precision_operator), shape (2y, x+1) instead of (2y, 2x) complex.
Apply the operator with rfft2 → multiply → irfft2(s=(2y, 2x)) → crop; the result is already real, so the trailing jnp.real goes away.
Change nothing else: the algebra is untouched, and the four consumers of apply_operator only ever call it.
Pin the change as exact — a new test compares apply_operator against an inline complex fft2/ifft2 reference at rtol=1e-10, on the small shared fixture and on a larger seeded case. A loosened tolerance would hide a bug.
Record in the docstrings why the real transform is exact here, with the measured saving and its source.
Confirm on the A100 before merging (see the /prm precondition below).
/prm precondition — A100 confirmation
The prompt requires: "GPU: the saving should hold or improve (half the FFT work and half the intermediate memory); confirm on the A100 before merging rather than assuming it."
There is no GPU in the development environment, so this PR ships on CPU parity plus the phase-2 measurements above. Before merge, run a short jax microbenchmark of apply_operator (280×280 preload, batch 128, old vs new) on RAL's A100 against a private merge-base checkout on PYTHONPATH (never the shared /mnt/ral/jnightin/PyAuto install), submitted through the project's hpc/sync push-submit gpu CLI, and paste the two timings into the PR before merging. The human decides at /prm whether to require it.
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py, InterferometerSparseOperator.from_nufft_precision_operator (~line 621): Khat = jnp.fft.rfft2(nufft_precision_operator) → shape (2y, x+1). Update the field comment to Khat: "jax.Array" # (2y, x+1), rfft2 of the real preload.
Leave the four consumers alone — curvature_matrix_diag_from, curvature_matrix_off_diag_from, curvature_matrix_func_list_from, operated_matrix_slim_from only call apply_operator.
Tests, test_autoarray/inversion/inversion/interferometer/test_inversion_interferometer_util.py: keep the existing dense-oracle tests (_operator_dense, pytest.approx(..., 1e-8)). Add test__apply_operator__rfft2_matches_complex_fft2_reference building the operator from the shared 7×7 / K=5 fixture and a larger seeded case (e.g. 12×12 mask, K=64), comparing apply_operator(np.eye(M)) against an inline complex fft2/ifft2 reference at rtol=1e-10, atol=1e-10·max|G|.
Existing test_interferometer.py comparisons against the mapping path (1e-4) remain the end-to-end gate.
Compatibility notes
The Khat shape change is invisible to serialisation: the class has no __getstate__ and the operator rides as pytree aux data via simulator.py:56 no_flatten.
Workspace .npy caches store the raw preload, not Khat, so they are unaffected.
Follow-on task (stacked on this branch): perf: build nufft_precision_operator_from as a type-1 NUFFT
Original Prompt
Click to expand starting prompt
InterferometerSparseOperator.apply_operator pads real input to complex — use rfft2/irfft2
Type: feature
Target: autoarray
Repos:
PyAutoArray
Themes:
interferometer
jax-performance
likelihood-profiling
Difficulty: small
Autonomy: supervised
Priority: high
Epic: numba-interferometer-revisit
Filed: 2026-09-07
Follow-up from autolens_profiling#226 (phase 2 of numba-interferometer-revisit); the
numbers are in autolens_profiling/results/notes/numba_interferometer_verdict.md and results/breakdown/interferometer/bakeoff_v2026.8.17.1.json.
What
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py
(InterferometerSparseOperator.apply_operator, ~689-728) zero-pads a real batch to (2y, 2x) and applies the operator with complex jnp.fft.fft2 / jnp.fft.ifft2, taking
the real part at the end. Khat = jnp.fft.fft2(nufft_precision_operator) is likewise the
complex transform of a real array. Both halve-able: the real-transform pair rfft2 / irfft2 computes the same result exactly (not approximately) with roughly half
the spectrum and half the butterflies.
Same synthetic inputs, same batch size (128), scipy stack, so the comparison isolates the
transform:
geometry (extent)
mesh
complex fft2
real rfft2
saving
sma (70²)
Delaunay S=1500
1.7479 s
1.2660 s
1.38×
alma (140²)
Delaunay S=1500
6.5003 s
4.3951 s
1.48×
alma_high (280²)
Delaunay S=1500
17.7362 s
11.0346 s
1.61×
sma (70²)
rect S=784
1.0124 s
0.7361 s
1.38×
alma (140²)
rect S=784
3.3786 s
2.6557 s
1.27×
alma_high (280²)
rect S=784
6.9360 s
4.8414 s
1.43×
rfft2 on the NumPy stack also beats the library's own JAX-CPU route by 1.10-1.30× at every
one of those cells, i.e. the transform change is worth more than the backend.
Scope
Change from_nufft_precision_operator to cache Khat = jnp.fft.rfft2(...) and apply_operator to rfft2 → multiply → irfft2(s=(2y, 2x)) → crop.
The parity gate is the existing interferometer inversion tests plus an explicit pin of curvature_matrix before/after at rtol=1e-10; the change is exact, so a loosened
tolerance would be hiding a bug.
GPU: the saving should hold or improve (half the FFT work and half the intermediate
memory); confirm on the A100 before merging rather than assuming it.
Nothing else in the interferometer path changes — the algebra is untouched.
Overview
InterferometerSparseOperator.apply_operatorzero-pads a real batch to(2y, 2x)and applies the operator with the complexjnp.fft.fft2/jnp.fft.ifft2pair, taking the real part at the end;Khat = jnp.fft.fft2(nufft_precision_operator)is likewise the complex transform of a real array. Because both inputs are real, the real-transform pairrfft2/irfft2computes the same result exactly — not approximately — with roughly half the spectrum and half the butterflies. Measured 1.27–1.61× on the operator apply across every geometry and mesh tested, and the NumPyrfft2route also beats the library's own JAX-CPU route by 1.10–1.30× at every cell.Follow-up from
autolens_profiling#226 (phase 2 of the retired epicnumba-interferometer-revisit, ledgerPyAutoMind/complete/archive/epics/numba_interferometer_likelihood_revisit.md). Numbers:autolens_profiling/results/notes/numba_interferometer_verdict.mdandresults/breakdown/interferometer/bakeoff_v2026.8.17.1.json.Measured saving
Single thread, i9-10885H, autoarray 2026.8.17.1; same synthetic inputs, same batch size (128), scipy stack, so the comparison isolates the transform:
fft2rfft2Plan
Khatas the real transform of the preload:jnp.fft.rfft2(nufft_precision_operator), shape(2y, x+1)instead of(2y, 2x)complex.rfft2→ multiply →irfft2(s=(2y, 2x))→ crop; the result is already real, so the trailingjnp.realgoes away.apply_operatoronly ever call it.apply_operatoragainst an inline complexfft2/ifft2reference atrtol=1e-10, on the small shared fixture and on a larger seeded case. A loosened tolerance would hide a bug./prmprecondition below)./prmprecondition — A100 confirmationThe prompt requires: "GPU: the saving should hold or improve (half the FFT work and half the intermediate memory); confirm on the A100 before merging rather than assuming it."
There is no GPU in the development environment, so this PR ships on CPU parity plus the phase-2 measurements above. Before merge, run a short
jaxmicrobenchmark ofapply_operator(280×280 preload, batch 128, old vs new) on RAL's A100 against a private merge-base checkout onPYTHONPATH(never the shared/mnt/ral/jnightin/PyAutoinstall), submitted through the project'shpc/sync push-submit gpuCLI, and paste the two timings into the PR before merging. The human decides at/prmwhether to require it.Detailed implementation plan
Work Classification
Library
Affected Repositories
Branch Survey
47a00e8c)Suggested branch:
feature/interferometer-apply-operator-rfft2Worktree root:
~/Code/PyAutoLabs-wt/interferometer-apply-operator-rfft2/Implementation Steps
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py,InterferometerSparseOperator.from_nufft_precision_operator(~line 621):Khat = jnp.fft.rfft2(nufft_precision_operator)→ shape(2y, x+1). Update the field comment toKhat: "jax.Array" # (2y, x+1), rfft2 of the real preload.apply_operator(~line 688):Fhat = jnp.fft.rfft2(F_pad);Ghat = Fhat * Khat[None];G_pad = jnp.fft.irfft2(Ghat, s=(2 * y_shape, 2 * x_shape))(already real — dropjnp.real); crop[:, :y_shape, :x_shape].curvature_matrix_diag_from,curvature_matrix_off_diag_from,curvature_matrix_func_list_from,operated_matrix_slim_fromonly callapply_operator.rfft2/irfft2is exact for a real preload and a real padded batch, plus the measured saving, citingautolens_profilingdocs: refactor docstrings for autoarray/mask package #226 / the verdict note.test_autoarray/inversion/inversion/interferometer/test_inversion_interferometer_util.py: keep the existing dense-oracle tests (_operator_dense,pytest.approx(..., 1e-8)). Addtest__apply_operator__rfft2_matches_complex_fft2_referencebuilding the operator from the shared 7×7 / K=5 fixture and a larger seeded case (e.g. 12×12 mask, K=64), comparingapply_operator(np.eye(M))against an inline complexfft2/ifft2reference atrtol=1e-10, atol=1e-10·max|G|.test_interferometer.pycomparisons against the mapping path (1e-4) remain the end-to-end gate.Compatibility notes
Khatshape change is invisible to serialisation: the class has no__getstate__and the operator rides as pytree aux data viasimulator.py:56 no_flatten..npycaches store the raw preload, notKhat, so they are unaffected.Verification
rfft2-vs-fft2pin passes atrtol=1e-10.pytest test_autoarray/inversion/inversion/interferometer test_autoarray/dataset/interferometer test_autoarray/operators -qgreen.test_autoarray/inversiontree green once.Key Files
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py—InterferometerSparseOperator(~line 538):from_nufft_precision_operator(~621) andapply_operator(~688).test_autoarray/inversion/inversion/interferometer/test_inversion_interferometer_util.py— parity pins.test_autoarray/inversion/inversion/interferometer/test_interferometer.py— end-to-end sparse-vs-mapping gate.Related
PyAutoMind/complete/archive/epics/numba_interferometer_likelihood_revisit.mdperf: build nufft_precision_operator_from as a type-1 NUFFTOriginal Prompt
Click to expand starting prompt
InterferometerSparseOperator.apply_operatorpads real input to complex — userfft2/irfft2Type: feature
Target: autoarray
Repos:
Themes:
Difficulty: small
Autonomy: supervised
Priority: high
Epic: numba-interferometer-revisit
Filed: 2026-09-07
Follow-up from
autolens_profiling#226(phase 2 ofnumba-interferometer-revisit); thenumbers are in
autolens_profiling/results/notes/numba_interferometer_verdict.mdandresults/breakdown/interferometer/bakeoff_v2026.8.17.1.json.What
autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py(
InterferometerSparseOperator.apply_operator, ~689-728) zero-pads a real batch to(2y, 2x)and applies the operator with complexjnp.fft.fft2/jnp.fft.ifft2, takingthe real part at the end.
Khat = jnp.fft.fft2(nufft_precision_operator)is likewise thecomplex transform of a real array. Both halve-able: the real-transform pair
rfft2/irfft2computes the same result exactly (not approximately) with roughly halfthe spectrum and half the butterflies.
Measured saving (single thread, i9-10885H, autoarray 2026.8.17.1)
Same synthetic inputs, same batch size (128), scipy stack, so the comparison isolates the
transform:
fft2rfft2rfft2on the NumPy stack also beats the library's own JAX-CPU route by 1.10-1.30× at everyone of those cells, i.e. the transform change is worth more than the backend.
Scope
from_nufft_precision_operatorto cacheKhat = jnp.fft.rfft2(...)andapply_operatortorfft2→ multiply →irfft2(s=(2y, 2x))→ crop.curvature_matrixbefore/after atrtol=1e-10; the change is exact, so a loosenedtolerance would be hiding a bug.
memory); confirm on the A100 before merging rather than assuming it.