Skip to content

perf: NumPy/scipy application path for InterferometerSparseOperator (xp=np never imports JAX) - #544

Merged
Jammy2211 merged 1 commit into
mainfrom
feature/interferometer-sparse-operator-numpy-cpu-path
Sep 8, 2026
Merged

Jammy2211 merged 1 commit into
mainfrom
feature/interferometer-sparse-operator-numpy-cpu-path

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Summary

InterferometerSparseOperator used jax.numpy unconditionally in every method, so a CPU
user running xp=np still executed the whole curvature build through JAX — 1.1–1.3× slower
than a NumPy/scipy rfft2 route, and it forced JAX into the process of a fit that had asked
for NumPy.

The operator now keeps the raw (2y, 2x) real preload as nufft_precision_operator and
builds both transforms of it lazily: Khat (JAX rfft2) and khat_np
(scipy.fft.rfft2). All six public methods take an xp array module and branch on it:

method JAX branch NumPy branch
apply_operator jnp.fft.rfft2 × Khatirfft2 scipy.fft.rfft2 × khat_npirfft2
curvature_matrix_diag_from lax.fori_loop + segment_sum scipy.sparse CSC A, Python block loop, Aᵀ @ G
curvature_matrix_off_diag_from same, over A1's columns same, over A1's columns
operated_matrix_slim_from .at[].set scatter fancy-index scatter
curvature_matrix_off_diag_func_list_from dynamic_update_slice dense slice assignment
curvature_matrix_func_list_from segment_sum Aᵀ @

Design: the backend follows the inversion's xp. InversionInterferometerSparse passes
xp=self._xp to every operator call, so what the fit asked for decides the backend. The
dataset's use_jax kwarg only picks a brute-force builder for the preload (since #541) and
never touches the application path. This is the repo's existing convention
(transformer.image_from(xp=)), and it removes the surprising split the issue names without
changing any dataset API. The JAX bodies are unchanged; the np.array(curvature_matrix)
workaround in sparse.py (needed only because the operator used to hand JAX arrays back to a
NumPy fit) is deleted.

No-JAX guarantee

A NumPy fit never imports JAX. The lazy Khat / col_offsets are the mechanism; a
subprocess test runs a full InversionInterferometerSparse(xp=np) fit and asserts
"jax" not in sys.modules afterwards. The new tests carry no importorskip("jax"), so the
unittest-nojax CI leg runs them.

Parity and speed

quantity NumPy vs JAX
all six methods (7×7/K=5, 12×12/K=64 seeded, batch_size=4 block loop, Delaunay duplicate-COO) ≤ 5e-16 relative
end-to-end InversionInterferometerSparse curvature matrix 2.8e-14
curvature_matrix_diag_from, 40×40 grid / S=400 3.8× faster than JAX-CPU

Pins are the repo idiom assert_allclose(rtol=1e-10, atol=1e-10*max|ref|).

Two traps worth recording

  • Tracer leak. Building Khat lazily inside a method that is itself traced (the JAX
    curvature bodies run under lax.fori_loop) turns the preload transform into a traced
    constant. jax.ensure_compile_time_eval() around the transform is what makes the laziness
    safe — without it the operator's static aux data leaks into the trace.
  • col = -1 padding. Delaunay's triplets are padded with col = -1 entries. JAX's
    segment_sum drops out-of-range segments silently; scipy.sparse.csc_matrix does not — it
    raises. The NumPy builder filters 0 <= col < S explicitly.

API Changes

Additive only. Six InterferometerSparseOperator methods gain a keyword-only xp=np
argument, defaulting to the NumPy branch. The dataclass gains a
nufft_precision_operator field (the raw preload, previously discarded after the FFT) and
Khat / col_offsets become cached properties rather than eager fields, with a new
khat_np alongside. No signature is broken and no symbol is removed.

See full details below.

Test Plan

  • pytest test_autoarray -q1486 passed
  • python -m black --check clean on all five files
  • Six-method NumPy-vs-JAX parity at the exact pin, on three fixtures plus a forced block loop
  • End-to-end inversion parity (curvature_matrix, data_vector, reconstruction, log_evidence)
  • Subprocess no-JAX test: "jax" not in sys.modules after a full NumPy fit
  • Every existing interferometer test and tolerance unchanged

Downstream note

PyAutoLens/autolens/potential_correction/fit_interferometer.py and
iterative_interferometer.py call curvature_matrix_diag_from without xp, so they now
take the NumPy branch. Both already wrap the result in np.asarray, so they work unchanged
and get faster. One profiling script,
autolens_profiling/scripts/interferometer/likelihood_breakdown/datacube/delaunay.py:1011,
also calls it without xp but passes JAX arrays inside a jit — it needs xp=jnp added; a
profiling-repo follow-up, no library or shipped-workspace impact.

Full API Changes (for automation & release notes)

Added

  • InterferometerSparseOperator.nufft_precision_operator: np.ndarray — the raw (2y, 2x)
    real preload, now kept rather than discarded after the FFT (the NumPy and forthcoming
    numba CPU paths index it directly).
  • InterferometerSparseOperator.khat_np — cached property, scipy.fft.rfft2 of the preload.

Changed Signature

  • InterferometerSparseOperator.apply_operator(Fbatch_flat, xp=np)
  • InterferometerSparseOperator.curvature_matrix_diag_from(rows, cols, vals, *, S, xp=np)
  • InterferometerSparseOperator.curvature_matrix_off_diag_from(rows0, cols0, vals0, rows1, cols1, vals1, *, S0, S1, xp=np)
  • InterferometerSparseOperator.operated_matrix_slim_from(matrix_slim, extent_index_for_masked_pixel, xp=np)
  • InterferometerSparseOperator.curvature_matrix_off_diag_func_list_from(..., xp=np)
  • InterferometerSparseOperator.curvature_matrix_func_list_from(..., xp=np)

All are additive: xp defaults to numpy.

Changed Behaviour

  • InterferometerSparseOperator.Khat and .col_offsets are cached properties, not eager
    dataclass fields; they are computed on first access under
    jax.ensure_compile_time_eval(). Reading them still returns JAX arrays.
  • InversionInterferometerSparse methods return NumPy arrays under xp=np (they returned
    JAX arrays before), so callers no longer need np.asarray.

Migration

  • Before: sparse_operator.curvature_matrix_diag_from(rows, cols, vals, S=S) — always JAX.
  • After: the same call runs NumPy/scipy; pass xp=jnp explicitly for the JAX branch (needed
    only if the arrays are traced).

Closes #542

Generated by the PyAutoLabs agent workflow.

🤖 Generated with Claude Code

https://claude.ai/code/session_018hLF3ZAcz5MmaSJBEcLkvF

…542)

The operator now keeps the raw (2y, 2x) real preload as
`nufft_precision_operator` and lazily builds both transforms of it: `Khat`
(JAX `rfft2`, under `jax.ensure_compile_time_eval()`) and `khat_np`
(`scipy.fft.rfft2`). An operator constructed and applied with `xp=np`
therefore never imports JAX.

All six public methods (`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`)
take an `xp` array module and branch: `jax.numpy` keeps the existing
`lax.fori_loop` / `segment_sum` / `dynamic_update_slice` bodies unchanged,
`numpy` (the default) runs scipy `rfft2`/`irfft2` with a `scipy.sparse` CSC
projection and a plain Python block loop (no padding or masking, which exist
only to keep traced shapes static). The diagonal NumPy body symmetrises like
the JAX one so the two agree to the pin.

`InversionInterferometerSparse` passes `xp=self._xp` to every operator call, so
the application backend follows the inversion's `xp` — what the fit passes —
rather than the dataset's `use_jax` kwarg, which after #541 only selects a
brute-force *builder* for the preload. The `np.array(curvature_matrix)`
workaround at sparse.py:171 (needed only because the operator used to return
JAX arrays on a NumPy fit) is deleted. Delaunay's padded `col = -1` triplets
are dropped explicitly when building the CSC matrix.

Verified: parity NumPy vs JAX at <= 5e-16 relative on all six methods
(7x7/K=5, 12x12/K=64 seeded, Delaunay duplicate-COO, and a `batch_size=4`
case exercising the block loop); end-to-end inversion curvature parity
2.8e-14; a subprocess test asserts `"jax" not in sys.modules` after a full
NumPy fit; NumPy `curvature_matrix_diag_from` is 3.8x faster than JAX-CPU on a
40x40 / S=400 probe. 1486 tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hLF3ZAcz5MmaSJBEcLkvF
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Sep 8, 2026
@Jammy2211
Jammy2211 merged commit 39d3024 into main Sep 8, 2026
3 checks passed
@Jammy2211
Jammy2211 deleted the feature/interferometer-sparse-operator-numpy-cpu-path branch September 8, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pending-release PR queued for the next release build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: NumPy/scipy application path for InterferometerSparseOperator (xp=np never imports JAX)

1 participant