Skip to content

Commit 86054bb

Browse files
author
James Nightingale
authored
Merge pull request #749 from PyAutoLabs/feature/point-source-cpu-p3
perf(point): JAX PointSolver step 0 deflects only the unique lattice vertices (PyAutoArray#568 phase 3)
2 parents 2aaa1c1 + c9ba49f commit 86054bb

2 files changed

Lines changed: 327 additions & 5 deletions

File tree

‎autolens/point/solver/shape_solver.py‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,40 @@ def _initial_triangles(self, xp):
108108
"""
109109
Build the initial triangle tiling for the stored image-plane extent, using
110110
the JAX or NumPy triangle implementation depending on ``xp``.
111+
112+
On the JAX path the tiling carries its static vertex table
113+
(``static_vertices=True``, see
114+
`autoarray.structures.triangles.coordinate_array.static_vertex_table`): the
115+
step-0 lattice depends only on the solver's geometry, which is static pytree
116+
aux data, so its geometrically unique vertices are a cached compile-time
117+
constant. Step 0's ``triangles.vertices`` -- the grid every step-0 deflection is
118+
evaluated on -- is then the ``(V, 2)`` unique table instead of the flat
119+
``(3N, 2)`` one (11 859 rather than 69 849 rows for the +-9.9" / 0.2" lattice
120+
of a 100x100, 0.2" grid), and ``triangles.indices`` gathers the traced
121+
vertices back to the triangles. Later steps are data-dependent and keep the
122+
flat table. A change of geometry changes the pytree aux data, so it retraces
123+
with the matching table. The NumPy path is unchanged (its sibling deduplicates
124+
with dynamic shapes).
111125
"""
112126
if xp.__name__.startswith("jax"):
113127
from autoarray.structures.triangles.coordinate_array import (
114-
CoordinateArrayTriangles as triangle_cls,
128+
CoordinateArrayTriangles,
115129
)
116-
else:
117-
from autoarray.structures.triangles.coordinate_array_np import (
118-
CoordinateArrayTrianglesNp as triangle_cls,
130+
131+
return CoordinateArrayTriangles.for_limits_and_scale(
132+
y_min=self.y_min,
133+
y_max=self.y_max,
134+
x_min=self.x_min,
135+
x_max=self.x_max,
136+
scale=self.scale,
137+
static_vertices=True,
119138
)
120139

121-
return triangle_cls.for_limits_and_scale(
140+
from autoarray.structures.triangles.coordinate_array_np import (
141+
CoordinateArrayTrianglesNp,
142+
)
143+
144+
return CoordinateArrayTrianglesNp.for_limits_and_scale(
122145
y_min=self.y_min,
123146
y_max=self.y_max,
124147
x_min=self.x_min,
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"""
2+
JAX tests of the static step-0 lattice on the `PointSolver` path (point-source CPU phase 3,
3+
PyAutoArray#568).
4+
5+
On the JAX path `AbstractSolver._initial_triangles` builds its tiling with
6+
``static_vertices=True``, so step 0 deflects only the geometrically unique lattice vertices
7+
(11 859 rather than 69 849 rows for the +-9.9" / 0.2" lattice of a 100x100, 0.2" grid) and
8+
gathers them back to the triangles. These tests pin that shape (the guard is red on the flat
9+
table), that a change of geometry changes the table, and that positions, image counts, the
10+
likelihood, `jax.grad` and `jax.vmap` agree with the flat per-triangle table the solver used
11+
before -- including near-caustic sources.
12+
13+
Every test skips on the NumPy-only (no ``[optional]`` extras) CI leg.
14+
"""
15+
16+
import importlib
17+
18+
import numpy as np
19+
import pytest
20+
21+
22+
# Plain data, defined outside the jax guard: module-level @parametrize reads it at collection
23+
# time, including on the NumPy-only matrix env.
24+
# Generic, near-caustic (the SIE's tangential caustic is ~0.1" across) and outer sources.
25+
SOURCES = [
26+
(0.07, 0.07),
27+
(0.0, 0.0),
28+
(0.012, 0.0),
29+
(0.0, 0.015),
30+
(0.03, 0.03),
31+
(0.3, -0.2),
32+
(1.2, 0.1),
33+
(1.58, 0.0),
34+
]
35+
36+
37+
if importlib.util.find_spec("jax") is None:
38+
pytestmark = pytest.mark.skip(reason="requires jax (the [optional] extras)")
39+
40+
def test__placeholder_requires_jax(): # pragma: no cover
41+
pass
42+
43+
else:
44+
import jax
45+
import jax.numpy as jnp
46+
47+
jax.config.update("jax_enable_x64", True)
48+
49+
import autolens as al
50+
from autolens.jax.registration import register_tracer_classes
51+
from autolens.point.solver.shape_solver import AbstractSolver
52+
from autoarray.structures.triangles.coordinate_array import (
53+
CoordinateArrayTriangles,
54+
static_vertex_table,
55+
)
56+
57+
def _solver(shape_native=(100, 100), pixel_scales=0.2):
58+
return al.PointSolver.for_grid(
59+
grid=al.Grid2D.uniform(
60+
shape_native=shape_native, pixel_scales=pixel_scales
61+
),
62+
pixel_scale_precision=0.001,
63+
magnification_threshold=0.1,
64+
use_jax=True,
65+
)
66+
67+
def _tracer(einstein_radius=1.6, source=(0.07, 0.07)):
68+
lens = al.Galaxy(
69+
redshift=0.5,
70+
mass=al.mp.Isothermal(
71+
centre=(0.0, 0.0),
72+
einstein_radius=einstein_radius,
73+
ell_comps=(0.05, 0.03),
74+
),
75+
)
76+
return al.Tracer(
77+
galaxies=[
78+
lens,
79+
al.Galaxy(redshift=1.0, point_0=al.ps.Point(centre=source)),
80+
]
81+
)
82+
83+
def _flat_initial_triangles(self, xp):
84+
"""The pre-phase-3 JAX step-0 tiling: the flat (3N, 2) per-triangle vertex table."""
85+
return CoordinateArrayTriangles.for_limits_and_scale(
86+
y_min=self.y_min,
87+
y_max=self.y_max,
88+
x_min=self.x_min,
89+
x_max=self.x_max,
90+
scale=self.scale,
91+
)
92+
93+
def _traced_grid_rows(solver, tracer, source, monkeypatch):
94+
"""Trace one jitted solve and record the row count of every deflected grid."""
95+
rows = []
96+
plane_grid = AbstractSolver._plane_grid
97+
98+
def recording(self, tracer, grid, xp, plane_redshift=None):
99+
rows.append(int(np.shape(grid)[0]))
100+
return plane_grid(self, tracer, grid, xp, plane_redshift)
101+
102+
monkeypatch.setattr(AbstractSolver, "_plane_grid", recording)
103+
jax.jit(
104+
lambda: solver.solve(tracer, source_plane_coordinate=source).array
105+
).lower()
106+
monkeypatch.setattr(AbstractSolver, "_plane_grid", plane_grid)
107+
return rows
108+
109+
def _solve(solver, einstein_radius, source):
110+
return np.asarray(
111+
jax.jit(
112+
lambda e, s: solver.solve(
113+
_tracer(e, (s[0], s[1])), source_plane_coordinate=(s[0], s[1])
114+
).array
115+
)(einstein_radius, jnp.array(source))
116+
)
117+
118+
def _finite_sorted(positions):
119+
positions = positions[np.all(np.isfinite(positions), axis=1)]
120+
return positions[np.lexsort((positions[:, 1], positions[:, 0]))]
121+
122+
123+
124+
def test__step_0_deflects_the_unique_lattice_vertices(monkeypatch):
125+
"""
126+
The shape guard: the first deflected grid of a jitted solve on the 100x100, 0.2" grid is the
127+
11 859-row unique-vertex table, not the 69 849-row flat one. Red before phase 3.
128+
"""
129+
solver = _solver()
130+
tracer = _tracer()
131+
register_tracer_classes(tracer)
132+
133+
rows = _traced_grid_rows(solver, tracer, (0.07, 0.07), monkeypatch)
134+
135+
assert rows[0] == 11859
136+
assert rows[0] != 3 * 23283
137+
assert len(rows) == solver.n_steps
138+
139+
140+
def test__changing_the_geometry_changes_the_step_0_table(monkeypatch):
141+
solver = _solver()
142+
tracer = _tracer()
143+
register_tracer_classes(tracer)
144+
145+
solver.scale = 0.3
146+
rows = _traced_grid_rows(solver, tracer, (0.07, 0.07), monkeypatch)
147+
148+
expected = static_vertex_table(
149+
float(solver.y_min),
150+
float(solver.y_max),
151+
float(solver.x_min),
152+
float(solver.x_max),
153+
0.3,
154+
)[0].shape[0]
155+
assert rows[0] == expected
156+
assert rows[0] != 11859
157+
158+
159+
@pytest.mark.parametrize("source", SOURCES)
160+
def test__positions_match_the_flat_table(source, monkeypatch):
161+
"""Identical image counts and positions to <= 1e-10 against the pre-phase-3 flat table."""
162+
solver = _solver()
163+
register_tracer_classes(_tracer())
164+
165+
static = _finite_sorted(_solve(solver, 1.6, source))
166+
167+
jax.clear_caches()
168+
monkeypatch.setattr(AbstractSolver, "_initial_triangles", _flat_initial_triangles)
169+
flat = _finite_sorted(_solve(solver, 1.6, source))
170+
171+
assert static.shape == flat.shape
172+
assert static.shape[0] > 0
173+
np.testing.assert_allclose(static, flat, rtol=0.0, atol=1e-10)
174+
175+
176+
@pytest.mark.parametrize("source", [(0.07, 0.07), (0.0, 0.015), (0.3, -0.2)])
177+
def test__positions_match_numpy(source):
178+
solver = _solver()
179+
register_tracer_classes(_tracer())
180+
181+
positions = _finite_sorted(_solve(solver, 1.6, source))
182+
183+
solver_np = al.PointSolver.for_grid(
184+
grid=al.Grid2D.uniform(shape_native=(100, 100), pixel_scales=0.2),
185+
pixel_scale_precision=0.001,
186+
magnification_threshold=0.1,
187+
)
188+
positions_np = _finite_sorted(
189+
np.asarray(
190+
solver_np.solve(_tracer(1.6, source), source_plane_coordinate=source).array
191+
)
192+
)
193+
194+
assert positions.shape == positions_np.shape
195+
np.testing.assert_allclose(positions, positions_np, rtol=0.0, atol=1e-10)
196+
197+
198+
def _log_likelihood_and_grad(solver):
199+
positions = al.Grid2DIrregular([(1.53, 0.35), (-1.24, -0.28)])
200+
noise_map = al.ArrayIrregular([0.05, 0.05])
201+
202+
def log_likelihood(einstein_radius):
203+
fit = al.FitPositionsImagePairAll(
204+
name="point_0",
205+
data=positions,
206+
noise_map=noise_map,
207+
tracer=_tracer(einstein_radius, (0.07, 0.07)),
208+
solver=solver,
209+
xp=jnp,
210+
)
211+
return fit.log_likelihood
212+
213+
value, grad = jax.jit(jax.value_and_grad(log_likelihood))(1.6)
214+
return float(value), float(grad)
215+
216+
217+
def test__log_likelihood_and_grad_match_the_flat_table(monkeypatch):
218+
solver = _solver()
219+
register_tracer_classes(_tracer())
220+
221+
value, grad = _log_likelihood_and_grad(solver)
222+
223+
jax.clear_caches()
224+
monkeypatch.setattr(AbstractSolver, "_initial_triangles", _flat_initial_triangles)
225+
value_flat, grad_flat = _log_likelihood_and_grad(solver)
226+
227+
assert np.isfinite(value)
228+
assert grad != 0.0, "jax.grad through the solver is silently zero"
229+
assert abs(value - value_flat) <= 1e-12 * abs(value_flat)
230+
assert grad == pytest.approx(grad_flat, rel=1e-10)
231+
232+
233+
def test__vmap_matches_scalar_solves():
234+
solver = _solver()
235+
register_tracer_classes(_tracer())
236+
237+
sources = jnp.array([(0.07, 0.07), (0.0, 0.015), (0.3, -0.2), (0.03, 0.03)])
238+
239+
batched = np.asarray(
240+
jax.jit(
241+
jax.vmap(
242+
lambda s: solver.solve(
243+
_tracer(1.6, (s[0], s[1])), source_plane_coordinate=(s[0], s[1])
244+
).array
245+
)
246+
)(sources)
247+
)
248+
249+
for i, source in enumerate(np.asarray(sources)):
250+
scalar = _finite_sorted(_solve(solver, 1.6, tuple(source)))
251+
np.testing.assert_allclose(
252+
_finite_sorted(batched[i]), scalar, rtol=0.0, atol=1e-10
253+
)
254+
255+
256+
def test__source_on_a_step_0_vertex_returns_the_two_true_images():
257+
"""
258+
Pinned tie regression (human gate decision, 2026-09-24: PASS).
259+
260+
The source of a simple SIE is placed bit-exactly on the image of the traced step-0 lattice
261+
vertex v = (-0.8, -1.99185843) (flat triangle vertex 31 732 of the +-9.9" / 0.2" lattice),
262+
and solved under ``jit`` with the source a traced input, as in a likelihood. The system has
263+
exactly two images: the vertex itself and a counter-image at ~(0.41013634, 0.89463539)
264+
(Newton roots of the lens equation; NumPy's 7 positions collapse onto the same two roots).
265+
266+
The static step-0 lattice returns exactly those two images. The pre-phase-3 flat table
267+
(main before PyAutoArray#568 phase 3) returns three here: it keeps two triangles either side
268+
of the tie vertex, |p - v| ~ 9e-4 each, which both Newton-converge to the vertex root -- it
269+
double-counts one image. That flat control is not self-consistent at such measure-zero ties
270+
either: eager and ``jit`` solves differ in the step-0 kept set and often in the image count,
271+
and a 1e-9 nudge of the source gives two images on both paths. The phase-3 image-count gate
272+
therefore passes this case as the removal of a duplicate, not the loss of an image.
273+
"""
274+
solver = _solver()
275+
register_tracer_classes(_tracer())
276+
277+
lattice = CoordinateArrayTriangles.for_limits_and_scale(
278+
y_min=-9.9, y_max=9.9, x_min=-9.9, x_max=9.9, scale=0.2
279+
)
280+
vertex = np.asarray(lattice.triangles).reshape(-1, 2)[31732]
281+
np.testing.assert_allclose(vertex, (-0.8, -1.99185843), rtol=0.0, atol=1e-8)
282+
283+
lens_only = al.Tracer(
284+
galaxies=[_tracer().galaxies[0], al.Galaxy(redshift=1.0)]
285+
)
286+
deflection = np.asarray(
287+
lens_only.deflections_yx_2d_from(
288+
grid=al.Grid2DIrregular([tuple(vertex)])
289+
).array
290+
)[0]
291+
source = tuple(float(value) for value in vertex - deflection)
292+
293+
positions = _finite_sorted(_solve(solver, 1.6, source))
294+
295+
true_images = np.array([vertex, (0.41013634, 0.89463539)])
296+
assert positions.shape == (2, 2)
297+
np.testing.assert_allclose(
298+
positions, true_images, rtol=0.0, atol=2.0 * solver.pixel_scale_precision
299+
)

0 commit comments

Comments
 (0)