Skip to content

Commit 2de4ca3

Browse files
committed
perf: cross-eval memo for MGE operated mapping matrices (numba CPU inversion)
A sampler builds fresh linear-func objects every likelihood evaluation, so with FIXED lens-light MGE parameters the identical ~60-Gaussian PSF convolution stack (~0.5 s of a ~2.4 s euclid numba CPU eval) is recomputed each call. InversionImagingSparseNumba now overrides linear_func_operated_mapping_matrix_dict with (1) per-inversion cached_property and (2) a module-level memo keyed by a sha256 of the linear func's full pickled state (profiles + grids + PSF): - fixed profiles fingerprint identically -> matrix reused across evals; - any free profile parameter changes the key -> recompute exactly as before (memo engages only when the MGE is actually fixed); - unpicklable objects fall back to the uncached parent computation; - failure modes are misses, never stale hits; entries are read-only copies, bounded at 8; AUTOARRAY_NUMBA_OPERATED_MEMO=0 disables. Scoped to imaging_numba/sparse.py only — no other inversion path, no public API, and no autogalaxy change (the batched-convolution half of the Mind prompt stays deferred). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcc7MUBMnNU6n8qqS9ioVZ
1 parent c7330a7 commit 2de4ca3

2 files changed

Lines changed: 245 additions & 0 deletions

File tree

autoarray/inversion/inversion/imaging_numba/sparse.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import hashlib
2+
import os
3+
import pickle
4+
15
import numpy as np
26
from typing import Dict, List, Optional, Union
37

@@ -14,6 +18,39 @@
1418

1519
from autoarray.inversion.inversion.imaging_numba import inversion_imaging_numba_util
1620

21+
# Cross-evaluation memo for linear-func operated mapping matrices (the MGE
22+
# lens-light PSF-convolved images). A sampler builds fresh linear-func objects
23+
# for every likelihood evaluation, so when the light profiles are FIXED in the
24+
# model the identical ~60-Gaussian convolution stack is recomputed each call —
25+
# ~0.5 s of a ~2.4 s euclid-resolution numba CPU evaluation (autolens_profiling
26+
# issue #151). Entries are keyed by a fingerprint of the linear-func object's
27+
# full pickled state (profiles + grids + PSF), so the memo engages only when
28+
# the profile parameters are genuinely unchanged; any varying parameter changes
29+
# the key and the matrix is recomputed exactly as before. Failure modes are
30+
# misses, never stale hits. Scoped to this numba inversion module on purpose —
31+
# no other path is touched. Disable with AUTOARRAY_NUMBA_OPERATED_MEMO=0.
32+
_operated_mapping_matrix_memo: Dict[str, np.ndarray] = {}
33+
34+
_OPERATED_MAPPING_MATRIX_MEMO_MAX_ENTRIES = 8
35+
36+
37+
def _operated_mapping_matrix_memo_key(linear_func) -> Optional[str]:
38+
"""
39+
Fingerprint a linear-func object's state for the cross-evaluation memo,
40+
or None if it cannot be fingerprinted (unpicklable), in which case the
41+
caller falls back to the uncached computation.
42+
43+
Must be called before the object's own cached properties are populated:
44+
a fingerprint taken afterwards would include the cached arrays and never
45+
match the pre-computation fingerprint of the next evaluation (a
46+
miss-every-time cache, still never a stale one).
47+
"""
48+
try:
49+
state = pickle.dumps(linear_func, protocol=pickle.HIGHEST_PROTOCOL)
50+
except Exception:
51+
return None
52+
return hashlib.sha256(state).hexdigest()
53+
1754

1855
class InversionImagingSparseNumba(AbstractInversionImaging):
1956
def __init__(
@@ -63,6 +100,76 @@ def psf_weighted_data(self):
63100
native_index_for_slim_index=self.data.mask.derive_indexes.native_for_slim,
64101
)
65102

103+
@cached_property
104+
def linear_func_operated_mapping_matrix_dict(self) -> Dict:
105+
"""
106+
The parent property, wrapped in two caches specific to this numba CPU
107+
inversion:
108+
109+
1. `cached_property` — the dict is built once per inversion instead of
110+
on every access (this inversion reads it from several matrices).
111+
2. A module-level cross-evaluation memo — when a linear func's full
112+
state (light profiles + grids + PSF) fingerprints identically to a
113+
previous evaluation's, its operated mapping matrix (the PSF-convolved
114+
MGE image stack, ~0.5 s/eval at euclid resolution) is reused instead
115+
of recomputed. Fixed-profile models hit every evaluation; models with
116+
free profile parameters change the fingerprint and recompute exactly
117+
as before. See the memo's module docstring for the safety argument.
118+
119+
Memoized matrices are returned read-only; every consumer in this class
120+
copies or derives from them (`np.array(...)`, divisions), never mutates.
121+
"""
122+
parent_fget = AbstractInversionImaging.linear_func_operated_mapping_matrix_dict.fget
123+
124+
if os.environ.get("AUTOARRAY_NUMBA_OPERATED_MEMO", "1") == "0":
125+
return parent_fget(self)
126+
127+
linear_func_list = self.cls_list_from(cls=AbstractLinearObjFuncList)
128+
129+
key_list = [
130+
_operated_mapping_matrix_memo_key(linear_func)
131+
for linear_func in linear_func_list
132+
]
133+
134+
if any(key is None for key in key_list):
135+
return parent_fget(self)
136+
137+
operated_mapping_matrix_dict = {}
138+
139+
for linear_func, key in zip(linear_func_list, key_list):
140+
operated_mapping_matrix = _operated_mapping_matrix_memo.get(key)
141+
142+
if operated_mapping_matrix is None:
143+
operated_mapping_matrix = linear_func.operated_mapping_matrix_override
144+
if operated_mapping_matrix is None:
145+
operated_mapping_matrix = self.psf.convolved_mapping_matrix_from(
146+
mapping_matrix=self._mapping_matrix_for_convolution_from(
147+
linear_func
148+
),
149+
mask=self.mask,
150+
xp=self._xp,
151+
)
152+
153+
# A copy, not a view: the memo owns its buffer outright, so
154+
# marking it read-only cannot leak onto the linear func's own
155+
# cached override array.
156+
operated_mapping_matrix = np.array(operated_mapping_matrix)
157+
operated_mapping_matrix.setflags(write=False)
158+
159+
while (
160+
len(_operated_mapping_matrix_memo)
161+
>= _OPERATED_MAPPING_MATRIX_MEMO_MAX_ENTRIES
162+
):
163+
_operated_mapping_matrix_memo.pop(
164+
next(iter(_operated_mapping_matrix_memo))
165+
)
166+
167+
_operated_mapping_matrix_memo[key] = operated_mapping_matrix
168+
169+
operated_mapping_matrix_dict[linear_func] = operated_mapping_matrix
170+
171+
return operated_mapping_matrix_dict
172+
66173
@property
67174
def _data_vector_mapper(self) -> np.ndarray:
68175
"""
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""
2+
The cross-evaluation memo for linear-func operated mapping matrices in the
3+
numba CPU sparse inversion (`imaging_numba/sparse.py`).
4+
5+
The memo must: reuse the matrix when a fresh linear-func object fingerprints
6+
identically to a previous evaluation's (the fixed-MGE campaign case); recompute
7+
when any state differs (free profile parameters); fall back to the uncached
8+
parent computation when an object cannot be fingerprinted or the memo is
9+
disabled; and never hand out writeable buffers.
10+
"""
11+
12+
import numpy as np
13+
import pytest
14+
15+
from autoarray.inversion.inversion.imaging_numba import sparse as sparse_module
16+
from autoarray.inversion.inversion.imaging_numba.sparse import (
17+
InversionImagingSparseNumba,
18+
_operated_mapping_matrix_memo,
19+
_operated_mapping_matrix_memo_key,
20+
)
21+
22+
23+
class FakeLinearFunc:
24+
"""Stands in for an MGE linear-func bundle: `values` plays the role of the
25+
profile parameters, and computing the override is counted class-wide so
26+
tests can assert whether the convolution work actually ran."""
27+
28+
compute_count = 0
29+
30+
def __init__(self, values):
31+
self.values = np.array(values, dtype=float)
32+
33+
@property
34+
def operated_mapping_matrix_override(self):
35+
type(self).compute_count += 1
36+
return np.outer(self.values, np.arange(1.0, 4.0))
37+
38+
39+
class UnpicklableLinearFunc(FakeLinearFunc):
40+
def __init__(self, values):
41+
super().__init__(values)
42+
self.blocker = lambda: None # lambdas cannot be pickled
43+
44+
45+
class StubInversion(InversionImagingSparseNumba):
46+
"""Bypasses the real constructor; the property under test only needs
47+
`cls_list_from` (and instance-dict storage for its cached_property)."""
48+
49+
def __init__(self, linear_func_list):
50+
self._stub_linear_func_list = list(linear_func_list)
51+
52+
def cls_list_from(self, cls):
53+
return self._stub_linear_func_list
54+
55+
56+
@pytest.fixture(autouse=True)
57+
def _clean_memo():
58+
_operated_mapping_matrix_memo.clear()
59+
FakeLinearFunc.compute_count = 0
60+
yield
61+
_operated_mapping_matrix_memo.clear()
62+
63+
64+
def test__memo_key__stable_for_equal_state__distinct_for_different_state():
65+
key_a = _operated_mapping_matrix_memo_key(FakeLinearFunc([1.0, 2.0]))
66+
key_b = _operated_mapping_matrix_memo_key(FakeLinearFunc([1.0, 2.0]))
67+
key_c = _operated_mapping_matrix_memo_key(FakeLinearFunc([1.0, 2.5]))
68+
69+
assert key_a == key_b
70+
assert key_a != key_c
71+
72+
73+
def test__memo_key__unpicklable_state_returns_none():
74+
assert _operated_mapping_matrix_memo_key(UnpicklableLinearFunc([1.0])) is None
75+
76+
77+
def test__identical_state_across_fresh_objects__computes_once():
78+
func_eval_0 = FakeLinearFunc([1.0, 2.0])
79+
dict_0 = StubInversion([func_eval_0]).linear_func_operated_mapping_matrix_dict
80+
81+
# A sampler's next evaluation builds a FRESH object with identical state.
82+
func_eval_1 = FakeLinearFunc([1.0, 2.0])
83+
dict_1 = StubInversion([func_eval_1]).linear_func_operated_mapping_matrix_dict
84+
85+
assert FakeLinearFunc.compute_count == 1
86+
assert np.array_equal(dict_0[func_eval_0], dict_1[func_eval_1])
87+
assert not dict_1[func_eval_1].flags.writeable
88+
89+
90+
def test__changed_state__recomputes_and_matches_uncached_result():
91+
StubInversion([FakeLinearFunc([1.0, 2.0])]).linear_func_operated_mapping_matrix_dict
92+
93+
func_changed = FakeLinearFunc([1.0, 3.0])
94+
result = StubInversion([func_changed]).linear_func_operated_mapping_matrix_dict[
95+
func_changed
96+
]
97+
98+
assert FakeLinearFunc.compute_count == 2
99+
assert np.array_equal(result, np.outer([1.0, 3.0], np.arange(1.0, 4.0)))
100+
101+
102+
def test__cached_property__single_dict_build_per_inversion():
103+
inversion = StubInversion([FakeLinearFunc([1.0, 2.0])])
104+
105+
dict_first = inversion.linear_func_operated_mapping_matrix_dict
106+
dict_second = inversion.linear_func_operated_mapping_matrix_dict
107+
108+
assert dict_first is dict_second
109+
110+
111+
def test__unpicklable_func__falls_back_to_uncached_parent_and_stores_nothing():
112+
func = UnpicklableLinearFunc([1.0, 2.0])
113+
114+
result = StubInversion([func]).linear_func_operated_mapping_matrix_dict[func]
115+
116+
assert np.array_equal(result, np.outer([1.0, 2.0], np.arange(1.0, 4.0)))
117+
assert len(_operated_mapping_matrix_memo) == 0
118+
119+
120+
def test__env_var_disables_memo(monkeypatch):
121+
monkeypatch.setenv("AUTOARRAY_NUMBA_OPERATED_MEMO", "0")
122+
123+
func = FakeLinearFunc([1.0, 2.0])
124+
result = StubInversion([func]).linear_func_operated_mapping_matrix_dict[func]
125+
126+
assert np.array_equal(result, np.outer([1.0, 2.0], np.arange(1.0, 4.0)))
127+
assert len(_operated_mapping_matrix_memo) == 0
128+
129+
130+
def test__memo_eviction__bounded_size():
131+
for value in range(sparse_module._OPERATED_MAPPING_MATRIX_MEMO_MAX_ENTRIES + 3):
132+
func = FakeLinearFunc([float(value)])
133+
StubInversion([func]).linear_func_operated_mapping_matrix_dict
134+
135+
assert (
136+
len(_operated_mapping_matrix_memo)
137+
== sparse_module._OPERATED_MAPPING_MATRIX_MEMO_MAX_ENTRIES
138+
)

0 commit comments

Comments
 (0)