Skip to content

Commit d43e1ac

Browse files
Jammy2211claude
andcommitted
perf(numba): FFT the mapper x linear-func block of the curvature matrix (#505)
The mapper x linear-func block of `F` was 70-85 % of F on every profiled cell (0.953 s of 1.275 s at HST resolution) and did not scale with the source mesh, only with image pixels. The cost was the dense sliding window inside `curvature_matrix_off_diags_via_mapper_and_linear_func_curvature_vector_from`: for every one of the 60 linear-func columns it expanded the noise-weighted curvature weights onto the native grid and ran an `ny x nx x ky x kx` correlation in numba. That correlation is exactly a convolution with the PSF reversed along both axes, so it now runs through the existing batched FFT convolver: - `Convolver.reversed_kernel` (new, cached) is the same convolver with its kernel reversed, reusing the preloaded `ConvolverState` geometry rebuilt for the reversed kernel. Cached because a dataset's PSF outlives the `Inversion` that is rebuilt for every likelihood evaluation, so the reversed kernel's FFT geometry is built once per fit rather than once per evaluation. - `InversionImagingSparseNumba._blurred_curvature_weights_from` correlates a linear func's curvature weights through it, once per linear func rather than once per (mapper, linear func) pair, with the weights zero outside the mask (no blurring mapping matrix) exactly as the sliding window had them. - `curvature_matrix_off_diags_via_mapper_and_blurred_curvature_weights_from` (new) is the scatter half of the old kernel: the genuinely sparse, irregular accumulation onto source pixels, which stays in numba. The old dense kernel and `convolve_with_kernel_native` are kept, unused by the inversion, as the reference the new path is asserted against in the tests. The numpy convolution path is `scipy.signal.convolve(..., mode="same")`, whose `(k - 1) // 2` "same" offset matches the kernel's `k // 2` centre for both odd and even kernel widths, and which spawns no thread pool (the profiling campaign runs one process per core). Tests: - `test_inversion_imaging_util.py` asserts the FFT block equals the dense kernel (rel 1e-6) on a small masked grid, parametrized over the file's existing asymmetric, non-square `KERNELS_ODD`; a control run without the reversal fails on all four. It also asserts `reversed_kernel` is the reversed kernel and is cached. - `test_curvature_matrix_func_list_blocks.py` gains an inversion-level test of `_curvature_matrix_mapper_func_blocks_from` against the dense kernel with an asymmetric PSF, which also pins that the block's transpose is written (the global mirror having been removed in the previous commit). A control run without the reversal fails. `pytest test_autoarray`: 1296 passed (1290 + 6 new). Seconds per evaluation, `OMP_NUM_THREADS=1 AUTOARRAY_NUMBA_OPERATED_MEMO=0`, n_repeats 10 (autolens_profiling breakdown cells): | cell | eval | F | mapper x l-func | |---------------------|-------------|-------------|-----------------| | hst bilinear | 1.738 -> 0.819 | 1.275 -> 0.387 | 0.953 -> 0.0629 | | euclid bilinear | 0.444 -> 0.273 | 0.284 -> 0.0889 | 0.198 -> 0.0224 | | hst Delaunay-1250 | 1.455 -> 0.754 | 1.055 -> 0.241 | 0.899 -> 0.0612 | | hst rectangular RTU | 8.501 -> 7.058 | 1.250 -> 0.335 | 0.929 -> 0.0630 | i.e. 15x on the block, 3.3x on F and 2.1x on the whole HST evaluation. Pinned log-likelihoods PASSED on all three pinned cells; F itself agrees with the sliding-window result to 3e-18 relative, and the hst figure of merit moves from 27661.910133664103 to 27661.91013366411 (9e-18 relative, well inside the harness rtol of 1e-4). Step 2 of #505. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fr6iJ5T1RDARWfxttCuGkK
1 parent cb91b76 commit d43e1ac

5 files changed

Lines changed: 439 additions & 17 deletions

File tree

‎autoarray/inversion/inversion/imaging_numba/inversion_imaging_numba_util.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,72 @@ def curvature_matrix_off_diags_via_sparse_operator_from(
674674
return curvature_matrix
675675

676676

677+
@numba_util.jit()
678+
def curvature_matrix_off_diags_via_mapper_and_blurred_curvature_weights_from(
679+
data_to_pix_unique: np.ndarray,
680+
data_weights: np.ndarray,
681+
pix_lengths: np.ndarray,
682+
pix_pixels: int,
683+
blurred_curvature_weights: np.ndarray, # shape (n_unmasked, n_funcs)
684+
) -> np.ndarray:
685+
"""
686+
Returns the off-diagonal terms in the curvature matrix `F` (see Warren & Dye 2003)
687+
between a mapper object and a linear func object, from curvature weights that have
688+
already been correlated with the PSF.
689+
690+
This is the scatter half of
691+
`curvature_matrix_off_diags_via_mapper_and_linear_func_curvature_vector_from`: that
692+
function expands the curvature weights onto the native grid, performs a dense
693+
sliding-window correlation with the PSF and then runs this loop. Splitting the two lets
694+
the correlation be done once per linear func by the batched FFT convolver (which is over
695+
an order of magnitude faster at HST resolution) while this loop, which is genuinely
696+
sparse and irregular, stays in numba and runs once per (mapper, linear func) pair.
697+
698+
For each unique mapping between a data pixel and a pixelization pixel, the PSF-correlated
699+
curvature weights at that data pixel are multiplied by the mapping weight and accumulated
700+
into the off-diagonal block of the curvature matrix. This accounts for sub-pixel mappings
701+
between data pixels and pixelization pixels.
702+
703+
Parameters
704+
----------
705+
data_to_pix_unique
706+
An array that maps every data pixel index (e.g. the masked image pixel indexes in 1D)
707+
to its unique set of pixelization pixel indexes (see `data_slim_to_pixelization_unique_from`).
708+
data_weights
709+
For every unique mapping between a set of data sub-pixels and a pixelization pixel,
710+
the weight of this mapping based on the number of sub-pixels that map to the pixelization pixel.
711+
pix_lengths
712+
A 1D array describing how many unique pixels each data pixel maps to. Used to iterate over
713+
`data_to_pix_unique` and `data_weights`.
714+
pix_pixels
715+
The total number of pixels in the pixelization that reconstructs the data.
716+
blurred_curvature_weights
717+
The operated values of the linear function divided by the noise-map squared and
718+
correlated with the PSF, with shape [n_unmasked_data_pixels, n_linear_func_pixels].
719+
720+
Returns
721+
-------
722+
ndarray
723+
The off-diagonal block of the curvature matrix `F` (see Warren & Dye 2003),
724+
with shape [pix_pixels, n_linear_func_pixels].
725+
"""
726+
data_pixels = data_weights.shape[0]
727+
n_funcs = blurred_curvature_weights.shape[1]
728+
729+
off_diag = np.zeros((pix_pixels, n_funcs))
730+
731+
for data_0 in range(data_pixels):
732+
for pix_0_index in range(pix_lengths[data_0]):
733+
data_0_weight = data_weights[data_0, pix_0_index]
734+
pix_0 = data_to_pix_unique[data_0, pix_0_index]
735+
for f in range(n_funcs):
736+
off_diag[pix_0, f] += (
737+
data_0_weight * blurred_curvature_weights[data_0, f]
738+
)
739+
740+
return off_diag
741+
742+
677743
@numba_util.jit()
678744
def curvature_matrix_off_diags_via_mapper_and_linear_func_curvature_vector_from(
679745
data_to_pix_unique: np.ndarray,
@@ -694,6 +760,13 @@ def curvature_matrix_off_diags_via_mapper_and_linear_func_curvature_vector_from(
694760
noise-map squared) are expanded into the native 2D image grid, convolved with the PSF
695761
kernel, and then remapped back to the 1D slim representation.
696762
763+
The inversion itself no longer calls this function: the correlation is performed by the
764+
batched FFT convolver (`Convolver.reversed_kernel`) and only the scatter/accumulate loop
765+
below runs in numba, via
766+
`curvature_matrix_off_diags_via_mapper_and_blurred_curvature_weights_from`. This dense
767+
sliding-window implementation is retained as the reference the FFT path is asserted
768+
against in the unit tests.
769+
697770
For each unique mapping between a data pixel and a pixelization pixel, the convolved
698771
curvature weights at that data pixel are multiplied by the mapping weights and
699772
accumulated into the off-diagonal block of the curvature matrix. This accounts for

‎autoarray/inversion/inversion/imaging_numba/sparse.py‎

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -532,16 +532,51 @@ def _curvature_matrix_func_list_and_mapper(self) -> np.ndarray:
532532

533533
return curvature_matrix
534534

535+
def _blurred_curvature_weights_from(
536+
self, curvature_weights: np.ndarray
537+
) -> np.ndarray:
538+
"""
539+
Returns a linear func's noise-weighted curvature weights correlated with the PSF, in the
540+
mask's slim representation with shape [n_unmasked_data_pixels, n_linear_func_pixels].
541+
542+
The mapper x linear-func block of `F` requires, at every unmasked data pixel, the
543+
sliding-window sum ``sum_dy_dx psf[dy, dx] * weights[y + dy - cy, x + dx - cx]`` -- a
544+
*correlation* with the PSF, not a convolution. Correlating with the PSF is exactly
545+
convolving with the PSF reversed along both axes, so this routes through the dataset
546+
PSF's `reversed_kernel` convolver and its batched (multi-column) convolution, which is
547+
over an order of magnitude faster at HST resolution than the dense sliding window it
548+
replaces.
549+
550+
As in the sliding-window implementation the weights are zero everywhere outside the
551+
mask (no blurring mapping matrix is supplied), and the result is read back only at the
552+
unmasked pixels.
553+
554+
Parameters
555+
----------
556+
curvature_weights
557+
The operated values of a linear function divided by the noise-map squared, with
558+
shape [n_unmasked_data_pixels, n_linear_func_pixels].
559+
"""
560+
return self.psf.reversed_kernel.convolved_mapping_matrix_from(
561+
mapping_matrix=curvature_weights,
562+
mask=self.mask,
563+
xp=np,
564+
)
565+
535566
def _curvature_matrix_mapper_func_blocks_from(
536567
self, curvature_matrix: np.ndarray
537568
) -> np.ndarray:
538569
"""
539570
Writes the mapper x linear-func off-diagonal blocks of the `curvature_matrix` into the input
540571
matrix, returning it.
541572
542-
Each block contracts a mapper's unique data-to-source-pixel mappings against the PSF-convolved,
543-
noise-weighted curvature vector of a linear function (the dense sliding-window correlation in
544-
`curvature_matrix_off_diags_via_mapper_and_linear_func_curvature_vector_from`).
573+
Each block contracts a mapper's unique data-to-source-pixel mappings against the PSF-correlated,
574+
noise-weighted curvature vector of a linear function.
575+
576+
The correlation is the dominant cost of F at HST resolution, so it is done once per linear
577+
func by `_blurred_curvature_weights_from` (batched FFT convolution) rather than once per
578+
(mapper, linear func) pair by a dense sliding window, and only the sparse scatter of the
579+
result onto source pixels runs in numba.
545580
546581
Each `[mapper, linear_func]` block is written together with its transpose into the
547582
`[linear_func, mapper]` block, so F leaves this helper symmetric and no global mirroring
@@ -556,17 +591,23 @@ def _curvature_matrix_mapper_func_blocks_from(
556591
mapper_list = self.cls_list_from(cls=Mapper)
557592
mapper_param_range_list = self.param_range_list_from(cls=Mapper)
558593

594+
if len(mapper_list) == 0:
595+
return curvature_matrix
596+
559597
linear_func_list = self.cls_list_from(cls=AbstractLinearObjFuncList)
560598
linear_func_param_range_list = self.param_range_list_from(
561599
cls=AbstractLinearObjFuncList
562600
)
563601

564-
# The noise-weighted curvature weights of a linear func do not depend on the mapper, so
565-
# they are formed once per linear func rather than once per (mapper, linear func) pair.
566-
curvature_weights_list = [
567-
np.array(
568-
self.linear_func_operated_mapping_matrix_dict[linear_func]
569-
/ self.noise_map[:, None] ** 2
602+
# Neither the noise-weighted curvature weights of a linear func nor their PSF
603+
# correlation depend on the mapper, so both are formed once per linear func rather
604+
# than once per (mapper, linear func) pair.
605+
blurred_curvature_weights_list = [
606+
self._blurred_curvature_weights_from(
607+
curvature_weights=np.array(
608+
self.linear_func_operated_mapping_matrix_dict[linear_func]
609+
/ self.noise_map[:, None] ** 2
610+
)
570611
)
571612
for linear_func in linear_func_list
572613
]
@@ -578,14 +619,14 @@ def _curvature_matrix_mapper_func_blocks_from(
578619
for func_index, linear_func in enumerate(linear_func_list):
579620
linear_func_param_range = linear_func_param_range_list[func_index]
580621

581-
off_diag = inversion_imaging_numba_util.curvature_matrix_off_diags_via_mapper_and_linear_func_curvature_vector_from(
622+
off_diag = inversion_imaging_numba_util.curvature_matrix_off_diags_via_mapper_and_blurred_curvature_weights_from(
582623
data_to_pix_unique=mapper.unique_mappings.data_to_pix_unique,
583624
data_weights=mapper.unique_mappings.data_weights,
584625
pix_lengths=mapper.unique_mappings.pix_lengths,
585626
pix_pixels=mapper.params,
586-
curvature_weights=curvature_weights_list[func_index],
587-
mask=self.mask.array,
588-
psf_kernel=self.psf.kernel.native.array,
627+
blurred_curvature_weights=blurred_curvature_weights_list[
628+
func_index
629+
],
589630
)
590631

591632
curvature_matrix[

‎autoarray/operators/convolver.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Optional, Tuple, Union
1111
import warnings
1212

13+
from autonerves import cached_property
1314
from autonerves import conf
1415
from autoarray.structures.arrays.uniform_2d import Array2D
1516
from autoarray.structures.grids.uniform_2d import Grid2D
@@ -317,6 +318,53 @@ def kernel_shape_image_resolution(self) -> Tuple[int, int]:
317318
2 * int(np.ceil((k // 2) / s)) + 1 for k in self.kernel.shape_native
318319
)
319320

321+
@cached_property
322+
def reversed_kernel(self) -> "Convolver":
323+
"""
324+
This convolver with its kernel reversed along both axes.
325+
326+
Convolving with the reversed kernel is exactly *correlating* with this convolver's
327+
kernel, because reversing both axes of one operand converts a convolution into a
328+
correlation::
329+
330+
(x * flip(k))[i] = sum_d k[d] x[i + d - c] = correlate(x, k)[i]
331+
332+
Callers whose operator is defined as a sliding-window correlation (for example the
333+
mapper x linear-func block of the imaging curvature matrix, which sums
334+
``psf[dy, dx] * image[y + dy - cy, x + dx - cx]``) can therefore route through the
335+
batched convolution machinery by convolving with this convolver instead of hand
336+
rolling the correlation.
337+
338+
The reversed convolver inherits this one's ``use_fft`` policy and
339+
``convolve_over_sample_size``, and reuses this convolver's ``ConvolverState``
340+
geometry (rebuilt for the reversed kernel, whose Fourier transform differs) when one
341+
was preloaded, so the FFT geometry is built once rather than once per call.
342+
343+
Cached, so a `Convolver` that outlives the objects using it (a dataset's PSF outlives
344+
the `Inversion` rebuilt for every likelihood evaluation) builds its reversed kernel and
345+
that kernel's FFT geometry only once.
346+
"""
347+
kernel = Array2D.no_mask(
348+
values=np.asarray(self.kernel.native.array)[::-1, ::-1].copy(),
349+
pixel_scales=self.kernel.pixel_scales,
350+
origin=self.kernel.origin,
351+
)
352+
353+
# An oversampled state carries sub-pixel permutations that only `state_from` can
354+
# attach, so it is left to rebuild that case rather than preloading a partial state.
355+
state = (
356+
ConvolverState(kernel=kernel, mask=self._state.source_mask)
357+
if self._state is not None and self.convolve_over_sample_size == 1
358+
else None
359+
)
360+
361+
return Convolver(
362+
kernel=kernel,
363+
state=state,
364+
use_fft=self._use_fft,
365+
convolve_over_sample_size=self.convolve_over_sample_size,
366+
)
367+
320368
def state_from(self, mask):
321369

322370
if self.convolve_over_sample_size > 1:

‎test_autoarray/inversion/inversion/imaging/test_inversion_imaging_util.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,3 +434,99 @@ def test__data_vector_via_blurred_mapping_matrix_from():
434434
)
435435

436436
assert (data_vector == np.array([2.0, 3.0, 1.0])).all()
437+
438+
439+
@pytest.mark.parametrize("kernel", KERNELS_ODD, ids=KERNEL_IDS)
440+
def test__curvature_matrix_off_diags_via_mapper_and_blurred_curvature_weights_from__matches_dense_kernel(
441+
kernel,
442+
):
443+
"""
444+
The mapper x linear-func block of `F` used to expand the curvature weights onto the
445+
native grid, run a dense sliding-window correlation with the PSF and scatter the result,
446+
all inside one numba kernel. The correlation now runs as a batched FFT convolution with
447+
the PSF reversed along both axes (`Convolver.reversed_kernel`) and only the scatter stays
448+
in numba.
449+
450+
This asserts the two produce the same block. The kernels are asymmetric and non-square,
451+
so a missing reversal (correlation computed as a convolution) or a transposed axis cannot
452+
pass.
453+
"""
454+
mask = aa.Mask2D(
455+
mask=np.array(
456+
[
457+
[True, True, True, True, True, True, True],
458+
[True, True, False, False, False, True, True],
459+
[True, False, False, False, False, False, True],
460+
[True, False, False, False, False, False, True],
461+
[True, False, False, False, False, False, True],
462+
[True, True, False, False, False, True, True],
463+
[True, True, True, True, True, True, True],
464+
]
465+
),
466+
pixel_scales=1.0,
467+
)
468+
469+
data_pixels = int(mask.pixels_in_mask)
470+
n_funcs = 3
471+
pix_pixels = 5
472+
473+
rng = np.random.default_rng(505)
474+
475+
curvature_weights = rng.normal(size=(data_pixels, n_funcs))
476+
477+
# Every data pixel maps to one or two source pixels, with non-uniform weights.
478+
max_lengths = 2
479+
pix_lengths = rng.integers(1, max_lengths + 1, size=data_pixels).astype("int")
480+
data_to_pix_unique = rng.integers(
481+
0, pix_pixels, size=(data_pixels, max_lengths)
482+
).astype("int")
483+
data_weights = rng.random(size=(data_pixels, max_lengths))
484+
485+
off_diag_dense = aa.util.inversion_imaging_numba.curvature_matrix_off_diags_via_mapper_and_linear_func_curvature_vector_from(
486+
data_to_pix_unique=data_to_pix_unique,
487+
data_weights=data_weights,
488+
pix_lengths=pix_lengths,
489+
pix_pixels=pix_pixels,
490+
curvature_weights=curvature_weights,
491+
mask=np.array(mask),
492+
psf_kernel=kernel,
493+
)
494+
495+
convolver = aa.Convolver(
496+
kernel=aa.Array2D.no_mask(values=kernel, pixel_scales=1.0),
497+
)
498+
499+
blurred_curvature_weights = convolver.reversed_kernel.convolved_mapping_matrix_from(
500+
mapping_matrix=curvature_weights,
501+
mask=mask,
502+
xp=np,
503+
)
504+
505+
off_diag_fft = aa.util.inversion_imaging_numba.curvature_matrix_off_diags_via_mapper_and_blurred_curvature_weights_from(
506+
data_to_pix_unique=data_to_pix_unique,
507+
data_weights=data_weights,
508+
pix_lengths=pix_lengths,
509+
pix_pixels=pix_pixels,
510+
blurred_curvature_weights=blurred_curvature_weights,
511+
)
512+
513+
assert off_diag_fft == pytest.approx(off_diag_dense, rel=1.0e-6)
514+
515+
516+
def test__convolver_reversed_kernel__is_the_kernel_reversed_and_convolves_as_a_correlation():
517+
"""
518+
`Convolver.reversed_kernel` is the same convolver with its kernel reversed along both
519+
axes, so convolving with it correlates with the original kernel.
520+
"""
521+
kernel = np.arange(1.0, 16.0).reshape(3, 5)
522+
523+
convolver = aa.Convolver(
524+
kernel=aa.Array2D.no_mask(values=kernel, pixel_scales=1.0),
525+
)
526+
527+
assert convolver.reversed_kernel.kernel.native.array == pytest.approx(
528+
kernel[::-1, ::-1]
529+
)
530+
531+
# Cached, so the reversed kernel and its FFT geometry are built once.
532+
assert convolver.reversed_kernel is convolver.reversed_kernel

0 commit comments

Comments
 (0)