Skip to content

Commit 52b84f5

Browse files
author
James Nightingale
authored
Merge pull request #516 from PyAutoLabs/feature/numpy-deflections-p1
perf(decorators): to_grid propagates only an explicit over_sampled; Grid2D.over_sampled short-circuits at sub-size 1 (#514)
2 parents 501c373 + 92bb9b2 commit 52b84f5

4 files changed

Lines changed: 147 additions & 4 deletions

File tree

‎autoarray/structures/decorators/to_grid.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,32 @@
88

99

1010
class GridMaker(AbstractMaker):
11+
# The private ``_over_sampled`` / ``_over_sampler`` attributes are read here
12+
# instead of the public properties on purpose: when ``result`` is already a
13+
# ``Grid2D`` (e.g. the chained ``@to_grid`` methods of the spherical mass
14+
# profiles) touching the properties *materialises* them, and building the
15+
# over sampled grid runs a per-pixel Python loop that dominates the runtime
16+
# of an otherwise millisecond deflection calculation. Only a value that was
17+
# explicitly passed in by the caller should propagate to the new grid; a
18+
# ``None`` lets the new ``Grid2D`` compute its own lazily, if anything ever
19+
# asks for it.
1120
def via_grid_2d(self, result) -> Union[Grid2D, List[Grid2D]]:
1221
if not isinstance(result, list):
1322
return Grid2D(
1423
values=result,
1524
mask=self.mask,
1625
over_sample_size=self.over_sample_size,
17-
over_sampled=getattr(result, "over_sampled", None),
18-
over_sampler=getattr(result, "over_sampler", None),
26+
over_sampled=getattr(result, "_over_sampled", None),
27+
over_sampler=getattr(result, "_over_sampler", None),
1928
)
2029

2130
return [
2231
Grid2D(
2332
values=res,
2433
mask=self.mask,
2534
over_sample_size=self.over_sample_size,
26-
over_sampled=getattr(res, "over_sampled", None),
27-
over_sampler=getattr(res, "over_sampler", None),
35+
over_sampled=getattr(res, "_over_sampled", None),
36+
over_sampler=getattr(res, "_over_sampler", None),
2837
)
2938
for res in result
3039
]

‎autoarray/structures/grids/uniform_2d.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,23 @@ def over_sampled(self):
213213
if self._over_sampled is not None:
214214
return self._over_sampled
215215

216+
if np.all(np.asarray(self.over_sample_size.array) == 1):
217+
# At a uniform sub size of 1 every sub pixel is the pixel itself, so the
218+
# over sampled grid is the slim grid in the same order. Short circuiting
219+
# here skips the per-pixel Python loop below, which dominates the runtime
220+
# of grid calculations that never touch the over sampled grid. The values
221+
# are copied so that in-place edits of `over_sampled` do not write through
222+
# to the grid itself, matching the behaviour of the loop below.
223+
grid_slim = grid_2d_util.convert_grid_2d_to_slim(
224+
grid_2d=self.array, mask_2d=self.mask, xp=self._xp
225+
)
226+
227+
self._over_sampled = Grid2DIrregular(
228+
values=self._xp.array(grid_slim), xp=self._xp
229+
)
230+
231+
return self._over_sampled
232+
216233
over_sampled = over_sample_util.grid_2d_slim_over_sampled_via_mask_from(
217234
mask_2d=np.array(self.mask),
218235
pixel_scales=self.mask.pixel_scales,

‎test_autoarray/structures/decorators/test_to_grid.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,72 @@ def test__in_ndarray__out_ndarray():
110110
assert isinstance(result, np.ndarray)
111111
assert not isinstance(result, aa.Grid2D)
112112
assert not isinstance(result, aa.Grid2DIrregular)
113+
114+
115+
class MockGrid2DPassThroughObj:
116+
"""
117+
Mimics the profile classes in **PyAutoGalaxy** whose `@to_grid` decorated methods
118+
delegate to another `@to_grid` decorated method, such that the value the decorator
119+
wraps up is already a `Grid2D`.
120+
"""
121+
122+
def __init__(self, over_sampled=None):
123+
self.centre = (0.0, 0.0)
124+
self.over_sampled = over_sampled
125+
126+
@aa.decorators.to_grid
127+
def grid_2d_from(self, grid, *args, **kwargs):
128+
return aa.Grid2D(
129+
values=np.multiply(2.0, grid.array),
130+
mask=grid.mask,
131+
over_sample_size=grid.over_sample_size,
132+
over_sampled=self.over_sampled,
133+
)
134+
135+
136+
def _mask_2x2():
137+
return aa.Mask2D(
138+
mask=[
139+
[True, True, True, True],
140+
[True, False, False, True],
141+
[True, False, False, True],
142+
[True, True, True, True],
143+
],
144+
pixel_scales=(1.0, 1.0),
145+
)
146+
147+
148+
def test__to_grid__does_not_materialise_over_sampled_of_wrapped_grid(monkeypatch):
149+
"""
150+
Reading the public `over_sampled` / `over_sampler` properties inside `to_grid`
151+
materialises them via a per-pixel Python loop, which dominates the runtime of
152+
deflection angle calculations. Only an explicitly set value may propagate.
153+
"""
154+
155+
def _fail(self):
156+
pytest.fail("over_sampled materialised inside to_grid")
157+
158+
def _fail_sampler(self):
159+
pytest.fail("over_sampler materialised inside to_grid")
160+
161+
monkeypatch.setattr(aa.Grid2D, "over_sampled", property(_fail))
162+
monkeypatch.setattr(aa.Grid2D, "over_sampler", property(_fail_sampler))
163+
164+
grid_2d = aa.Grid2D.from_mask(mask=_mask_2x2(), over_sample_size=2)
165+
166+
result = MockGrid2DPassThroughObj().grid_2d_from(grid=grid_2d)
167+
168+
assert isinstance(result, aa.Grid2D)
169+
assert result._over_sampled is None
170+
assert result._over_sampler is None
171+
172+
173+
def test__to_grid__propagates_explicitly_set_over_sampled():
174+
grid_2d = aa.Grid2D.from_mask(mask=_mask_2x2(), over_sample_size=2)
175+
176+
sentinel = aa.Grid2DIrregular(values=[(1.0, 2.0), (3.0, 4.0)])
177+
178+
result = MockGrid2DPassThroughObj(over_sampled=sentinel).grid_2d_from(grid=grid_2d)
179+
180+
assert result._over_sampled is sentinel
181+
assert result.over_sampled is sentinel

‎test_autoarray/structures/grids/test_uniform_2d.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,3 +880,51 @@ def test__subtracted_and_rotated_from__shift_first_then_rotate():
880880

881881
rotated = grid.subtracted_and_rotated_from(offset=(1.0, 2.0), angle=90.0)
882882
assert rotated.array == pytest.approx(expected, 1.0e-4)
883+
884+
885+
def test__over_sampled__sub_size_1_is_the_slim_grid():
886+
mask = aa.Mask2D(
887+
mask=[
888+
[True, True, True, True, True],
889+
[True, False, False, False, True],
890+
[True, False, True, False, True],
891+
[True, False, False, False, True],
892+
[True, True, True, True, True],
893+
],
894+
pixel_scales=(1.5, 1.5),
895+
)
896+
897+
grid = aa.Grid2D.from_mask(mask=mask, over_sample_size=1)
898+
899+
over_sampled = grid.over_sampled
900+
901+
assert isinstance(over_sampled, aa.Grid2DIrregular)
902+
assert np.array_equal(np.array(over_sampled), np.array(grid.array))
903+
904+
# The cached value is not a view onto the grid itself.
905+
assert not np.shares_memory(np.array(over_sampled.array), np.array(grid.array))
906+
907+
# The property caches, so a second read is the same object.
908+
assert grid.over_sampled is over_sampled
909+
910+
911+
def test__over_sampled__sub_size_2_over_samples_every_pixel():
912+
mask = aa.Mask2D(
913+
mask=[
914+
[True, True, True, True, True],
915+
[True, False, False, False, True],
916+
[True, False, True, False, True],
917+
[True, False, False, False, True],
918+
[True, True, True, True, True],
919+
],
920+
pixel_scales=(1.5, 1.5),
921+
)
922+
923+
grid = aa.Grid2D.from_mask(mask=mask, over_sample_size=2)
924+
925+
over_sampled = grid.over_sampled
926+
927+
assert np.array(over_sampled).shape[0] == 4 * grid.array.shape[0]
928+
assert not np.array_equal(
929+
np.array(over_sampled)[: grid.array.shape[0]], np.array(grid.array)
930+
)

0 commit comments

Comments
 (0)