Skip to content

Commit 7a4cb70

Browse files
authored
Merge pull request #540 from PyAutoLabs/feature/interferometer-apply-operator-rfft2
perf: apply the interferometer sparse operator with rfft2/irfft2
2 parents 47a00e8 + a67828e commit 7a4cb70

2 files changed

Lines changed: 116 additions & 29 deletions

File tree

autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ class InterferometerSparseOperator:
563563
M: int
564564
batch_size: int
565565
w_dtype: "jax.numpy.dtype"
566-
Khat: "jax.Array" # (2y, 2x), complex
566+
Khat: "jax.Array" # (2y, x+1), rfft2 of the real preload
567567
col_offsets: "jax.Array" # (batch_size,) int32
568568
"""
569569
Cached FFT operator state for fast interferometer curvature-matrix assembly.
@@ -578,9 +578,10 @@ class InterferometerSparseOperator:
578578
By taking an FFT of this preload, the operator can be applied to batches of
579579
images via elementwise multiplication in Fourier space:
580580
581-
apply_W(F) = IFFT( FFT(F_pad) * Khat )
581+
apply_W(F) = IRFFT( RFFT(F_pad) * Khat )
582582
583-
where `F_pad` is a (2y, 2x) padded version of `F` and `Khat = FFT(nufft_precision_operator)`.
583+
where `F_pad` is a (2y, 2x) padded version of `F` and
584+
`Khat = rfft2(nufft_precision_operator)`.
584585
585586
The curvature matrix for a pixelization (mapper) is then assembled from sparse
586587
mapping triplets without forming dense mapping matrices:
@@ -614,7 +615,7 @@ class InterferometerSparseOperator:
614615
w_dtype
615616
Floating-point dtype for weights and accumulations (e.g. float64).
616617
Khat
617-
FFT of the curvature preload, shape (2y_shape, 2x_shape), complex.
618+
Real FFT of the curvature preload, shape (2y_shape, x_shape + 1), complex.
618619
This is the frequency-domain representation of the W~ operator kernel.
619620
"""
620621

@@ -633,8 +634,9 @@ def from_nufft_precision_operator(
633634
634635
The curvature preload is assumed to be defined on a (2y, 2x) rectangular
635636
grid of pixel offsets, where y and x correspond to the *unmasked extent*
636-
of the real-space grid. The preload is FFT'd once to obtain `Khat`, which
637-
is then reused for every subsequent curvature matrix build.
637+
of the real-space grid. The preload is real, so it is transformed once with
638+
a real FFT (`rfft2`) to obtain `Khat` of shape (2y, x + 1), which is then
639+
reused for every subsequent curvature matrix build.
638640
639641
Parameters
640642
----------
@@ -654,7 +656,8 @@ def from_nufft_precision_operator(
654656
Returns
655657
-------
656658
InterferometerSparseOperator
657-
Immutable cached state object containing shapes and FFT kernel `Khat`.
659+
Immutable cached state object containing shapes and FFT kernel `Khat`,
660+
of shape (2y, x + 1) and complex dtype.
658661
659662
Raises
660663
------
@@ -673,7 +676,7 @@ def from_nufft_precision_operator(
673676
x_shape = W2 // 2
674677
M = y_shape * x_shape
675678

676-
Khat = jnp.fft.fft2(nufft_precision_operator)
679+
Khat = jnp.fft.rfft2(nufft_precision_operator)
677680

678681
return InterferometerSparseOperator(
679682
dirty_image=dirty_image,
@@ -697,10 +700,19 @@ def apply_operator(self, Fbatch_flat):
697700
698701
via FFT-based convolution with the cached `Khat` kernel:
699702
700-
apply_W(F) = Re( IFFT( FFT(F_pad) * Khat ) )[:y, :x]
703+
apply_W(F) = IRFFT( RFFT(F_pad) * Khat )[:y, :x]
701704
702705
where `F_pad` is the (2y, 2x) zero-padded version of `F`.
703706
707+
Both the preload and the batch are real-valued, so the real-transform pair
708+
(`rfft2` / `irfft2`) is exact here rather than an approximation: the discarded
709+
half of the spectrum is the conjugate mirror of the half that is kept, and the
710+
inverse real transform reconstructs it, so the product is identical to the
711+
complex `fft2` / `ifft2` route to floating-point round-off (the old code took
712+
`Re(...)` of an already-real result). It halves the transform work and the size
713+
of `Khat`, measured at 1.27-1.61x faster on every backend (autolens_profiling
714+
#226, `results/notes/numba_interferometer_verdict.md`).
715+
704716
Parameters
705717
----------
706718
Fbatch_flat
@@ -720,10 +732,10 @@ def apply_operator(self, Fbatch_flat):
720732
B = Fbatch_flat.shape[1]
721733
F_img = Fbatch_flat.T.reshape((B, y_shape, x_shape))
722734
F_pad = jnp.pad(F_img, ((0, 0), (0, y_shape), (0, x_shape)))
723-
Fhat = jnp.fft.fft2(F_pad)
735+
Fhat = jnp.fft.rfft2(F_pad)
724736
Ghat = Fhat * Khat[None, :, :]
725-
G_pad = jnp.fft.ifft2(Ghat)
726-
G = jnp.real(G_pad[:, :y_shape, :x_shape])
737+
G_pad = jnp.fft.irfft2(Ghat, s=(2 * y_shape, 2 * x_shape))
738+
G = G_pad[:, :y_shape, :x_shape]
727739
return G.reshape((B, M)).T
728740

729741
def curvature_matrix_diag_from(self, rows, cols, vals, *, S: int):

test_autoarray/inversion/inversion/interferometer/test_inversion_interferometer_util.py

Lines changed: 92 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,30 @@ def test__data_vector_via_transformed_mapping_matrix_from():
6868
assert (data_vector_complex_via_blurred == data_vector_via_transformed).all()
6969

7070

71-
def _sparse_operator_and_mask():
71+
def _dataset_from(mask, n_visibilities, seed):
7272
"""
73-
Returns a real `InterferometerSparseOperator` (and the mask it is defined on) built from a
74-
small 7x7 `TransformerDFT` interferometer dataset.
73+
Returns a small `TransformerDFT` interferometer dataset on the input mask, with `n_visibilities`
74+
seeded random visibilities and unit noise, alongside the random generator used to build it.
7575
"""
76-
mask = aa.Mask2D(
76+
rng = np.random.default_rng(seed=seed)
77+
78+
dataset = aa.Interferometer(
79+
data=aa.Visibilities(
80+
visibilities=rng.normal(size=(n_visibilities, 2)).astype(np.float64)
81+
),
82+
noise_map=aa.VisibilitiesNoiseMap(
83+
visibilities=np.ones((n_visibilities, 2), dtype=np.float64)
84+
),
85+
uv_wavelengths=rng.normal(size=(n_visibilities, 2)).astype(np.float64),
86+
real_space_mask=mask,
87+
transformer_class=aa.TransformerDFT,
88+
)
89+
90+
return dataset, rng
91+
92+
93+
def _mask_7x7():
94+
return aa.Mask2D(
7795
mask=[
7896
[True, True, True, True, True, True, True],
7997
[True, True, True, True, True, True, True],
@@ -86,20 +104,15 @@ def _sparse_operator_and_mask():
86104
pixel_scales=2.0,
87105
)
88106

89-
n_visibilities = 5
90-
rng = np.random.default_rng(seed=3)
91107

92-
dataset = aa.Interferometer(
93-
data=aa.Visibilities(
94-
visibilities=rng.normal(size=(n_visibilities, 2)).astype(np.float64)
95-
),
96-
noise_map=aa.VisibilitiesNoiseMap(
97-
visibilities=np.ones((n_visibilities, 2), dtype=np.float64)
98-
),
99-
uv_wavelengths=rng.normal(size=(n_visibilities, 2)).astype(np.float64),
100-
real_space_mask=mask,
101-
transformer_class=aa.TransformerDFT,
102-
)
108+
def _sparse_operator_and_mask():
109+
"""
110+
Returns a real `InterferometerSparseOperator` (and the mask it is defined on) built from a
111+
small 7x7 `TransformerDFT` interferometer dataset.
112+
"""
113+
mask = _mask_7x7()
114+
115+
dataset, rng = _dataset_from(mask=mask, n_visibilities=5, seed=3)
103116

104117
return dataset.apply_sparse_operator(use_jax=False).sparse_operator, mask, rng
105118

@@ -253,3 +266,65 @@ def test__interferometer_sparse_operator__operated_matrix_slim_from():
253266

254267
assert operated.shape == (mask.pixels_in_mask, 2)
255268
assert operated == pytest.approx(operated_dense, 1.0e-8)
269+
270+
271+
def _apply_operator_via_complex_fft2(preload, operator):
272+
"""
273+
Returns `W~ @ I` computed with the complex `fft2` / `ifft2` pair, written out in NumPy so that
274+
it is an independent reference for the `rfft2` / `irfft2` implementation in
275+
`InterferometerSparseOperator.apply_operator`.
276+
"""
277+
y_shape, x_shape = operator.y_shape, operator.x_shape
278+
M = operator.M
279+
280+
Fbatch_flat = np.eye(M)
281+
B = Fbatch_flat.shape[1]
282+
283+
F_img = Fbatch_flat.T.reshape((B, y_shape, x_shape))
284+
F_pad = np.pad(F_img, ((0, 0), (0, y_shape), (0, x_shape)))
285+
286+
Khat = np.fft.fft2(preload)
287+
Ghat = np.fft.fft2(F_pad) * Khat[None, :, :]
288+
G_pad = np.fft.ifft2(Ghat)
289+
G = np.real(G_pad[:, :y_shape, :x_shape])
290+
291+
return G.reshape((B, M)).T
292+
293+
294+
def test__interferometer_sparse_operator__apply_operator__rfft2_matches_complex_fft2_reference():
295+
pytest.importorskip("jax")
296+
297+
cases = [
298+
(_mask_7x7(), 5, 3),
299+
(
300+
aa.Mask2D.circular(shape_native=(12, 12), pixel_scales=1.0, radius=4.0),
301+
64,
302+
11,
303+
),
304+
]
305+
306+
for mask, n_visibilities, seed in cases:
307+
dataset, _ = _dataset_from(mask=mask, n_visibilities=n_visibilities, seed=seed)
308+
309+
preload = dataset.psf_precision_operator_from(use_jax=False)
310+
operator = dataset.apply_sparse_operator(
311+
nufft_precision_operator=preload
312+
).sparse_operator
313+
314+
# The preload and the batch are both real, so `rfft2` stores only the non-redundant half
315+
# of the spectrum: (2y, x + 1) rather than (2y, 2x).
316+
assert operator.Khat.shape == (2 * operator.y_shape, operator.x_shape + 1)
317+
318+
operated = np.array(operator.apply_operator(np.eye(operator.M)))
319+
operated_via_complex_fft2 = _apply_operator_via_complex_fft2(
320+
preload=preload, operator=operator
321+
)
322+
323+
# The real transform pair is exact for a real preload and a real batch, so this pin is at
324+
# round-off, not at an algorithmic tolerance.
325+
np.testing.assert_allclose(
326+
operated,
327+
operated_via_complex_fft2,
328+
rtol=1.0e-10,
329+
atol=1.0e-10 * np.abs(operated_via_complex_fft2).max(),
330+
)

0 commit comments

Comments
 (0)