Skip to content

Commit e994485

Browse files
authored
Merge pull request #421 from PyAutoLabs/feature/simulator-jax-xp-threading
fix: make the imaging simulator's @jax.jit path work (5 xp / pytree sites)
2 parents 41c55a4 + 854fac8 commit e994485

5 files changed

Lines changed: 97 additions & 6 deletions

File tree

autoarray/abstract_ndarray.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,20 @@ def is_transformed(self, value: bool):
164164

165165
@property
166166
def _xp(self):
167-
if self.use_jax:
167+
"""The array module matching the array this instance actually holds.
168+
169+
``use_jax`` records how the instance was *constructed*, and not every
170+
construction site threads ``xp`` — an intermediate operation can hand
171+
back an instance whose flag says NumPy while its backing array is a JAX
172+
tracer. Consumers of ``_xp`` (notably ``Array2D.native``, which is a
173+
property and so cannot be passed ``xp`` by its caller) would then route a
174+
traced array through the NumPy path and raise
175+
``TracerArrayConversionError`` inside a ``jax.jit`` trace.
176+
177+
So the backing array is authoritative: if it is a JAX type, this is
178+
``jnp`` regardless of the flag. On the NumPy path nothing changes.
179+
"""
180+
if self.use_jax or type(self._array).__module__.startswith(("jax", "jaxlib")):
168181
import jax.numpy as jnp
169182

170183
return jnp

autoarray/dataset/imaging/simulator.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,57 @@
1212

1313
logger = logging.getLogger(__name__)
1414

15+
_IMAGING_PYTREES_REGISTERED = False
16+
17+
18+
def _register_imaging_pytrees() -> None:
19+
"""Register ``Imaging`` so ``jax.jit(via_image_from)`` can flatten its return.
20+
21+
Counterpart of ``AnalysisImaging._register_fit_imaging_pytrees``, which does
22+
the same job for ``FitImaging``. Without it a jitted simulator call raises
23+
``TypeError: ... returned a value of type Imaging, which is not a valid JAX
24+
type``.
25+
26+
``data`` and ``noise_map`` are the only per-simulation dynamic values, so
27+
everything else rides as aux: ``psf`` and ``grids`` are constants for a given
28+
simulation, the over-sample sizes are static integer geometry, and the
29+
remaining slots are ``None``.
30+
31+
Unlike pytree registration for a jitted function's *arguments* — which must
32+
happen before the first call, because JAX flattens arguments at trace time —
33+
registering here is in time, because the return value is flattened only after
34+
the body has run. Idempotent via the module-level flag.
35+
"""
36+
global _IMAGING_PYTREES_REGISTERED
37+
if _IMAGING_PYTREES_REGISTERED:
38+
return
39+
40+
from autoarray.abstract_ndarray import register_instance_pytree
41+
42+
# ``Array2D.instance_flatten`` emits every ``__dict__`` entry not opted out,
43+
# so a flattened ``data`` exposes its ``mask`` as a child. ``Mask2D`` must
44+
# therefore be a pytree itself, or it surfaces as a bare leaf and JAX
45+
# rejects the return value. Registering it here rather than adding ``mask``
46+
# to ``Array2D.__no_flatten__`` keeps ``Array2D``'s flatten semantics
47+
# unchanged for every other jitted path in the stack.
48+
register_instance_pytree(Mask2D)
49+
50+
register_instance_pytree(
51+
Imaging,
52+
no_flatten=(
53+
"psf",
54+
"grids",
55+
"over_sample_size_lp",
56+
"over_sample_size_pixelization",
57+
"convolve_over_sample_size_lp",
58+
"convolve_over_sample_size_pixelization",
59+
"noise_covariance_matrix",
60+
"sparse_operator",
61+
),
62+
)
63+
64+
_IMAGING_PYTREES_REGISTERED = True
65+
1566

1667
class SimulatorImaging:
1768
def __init__(
@@ -162,6 +213,9 @@ def via_image_from(
162213
if xp is None:
163214
xp = self._xp
164215

216+
if xp is not np:
217+
_register_imaging_pytrees()
218+
165219
exposure_time_map = Array2D.full(
166220
fill_value=self.exposure_time,
167221
shape_native=image.shape_native,
@@ -201,7 +255,9 @@ def via_image_from(
201255

202256
if self.include_poisson_noise_in_noise_map:
203257
noise_map = preprocess.noise_map_via_data_eps_and_exposure_time_map_from(
204-
data_eps=image_with_poisson_noise, exposure_time_map=exposure_time_map
258+
data_eps=image_with_poisson_noise,
259+
exposure_time_map=exposure_time_map,
260+
xp=xp,
205261
)
206262

207263
else:

autoarray/dataset/preprocess.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ def array_with_random_uniform_values_added(array, upper_limit=0.001):
132132
return array + upper_limit * np.random.uniform(size=array.shape_slim)
133133

134134

135-
def noise_map_via_data_eps_and_exposure_time_map_from(data_eps, exposure_time_map):
135+
def noise_map_via_data_eps_and_exposure_time_map_from(
136+
data_eps, exposure_time_map, xp=np
137+
):
136138
"""
137139
Estimate the noise-map value in every data-point, by converting the data to units of counts and taking the
138140
square root of these values.
@@ -148,9 +150,13 @@ def noise_map_via_data_eps_and_exposure_time_map_from(data_eps, exposure_time_ma
148150
The data in electrons second used to estimate the Poisson noise in every data point.
149151
exposure_time_map
150152
The exposure time at every data-point of the data.
153+
xp
154+
The array module (``numpy`` or ``jax.numpy``). Must be ``jnp`` when
155+
``data_eps`` carries a traced array, otherwise ``np.abs`` raises
156+
``TracerArrayConversionError`` inside a ``jax.jit`` trace.
151157
"""
152158
return data_eps.with_new_array(
153-
np.abs(data_eps.array * exposure_time_map.array) ** 0.5
159+
xp.abs(data_eps.array * exposure_time_map.array) ** 0.5
154160
/ exposure_time_map.array
155161
)
156162

autoarray/structures/arrays/array_2d_util.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,15 @@ def array_2d_native_from(
509509

510510
shape = (mask_2d.shape[0], mask_2d.shape[1])
511511

512+
# Deliberately NumPy, not ``xp``: this index map is derived from the mask,
513+
# which is concrete geometry and never traced. Its computation is a
514+
# ``where(~mask)``, whose output shape depends on the *values* of the mask, so
515+
# under ``jnp`` inside a ``jax.jit`` trace it raises
516+
# ``ConcretizationTypeError``. Computing it in NumPy yields a concrete index
517+
# array, which is a valid static operand for the scatter below — that is
518+
# where ``xp`` genuinely belongs.
512519
native_index_for_slim_index_2d = mask_2d_util.native_index_for_slim_index_2d_from(
513-
mask_2d=mask_2d, xp=xp
520+
mask_2d=mask_2d
514521
).astype("int")
515522

516523
return array_2d_via_indexes_from(

autoarray/structures/arrays/uniform_2d.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,18 @@ def native(self) -> "Array2D":
291291
292292
If it is already stored in its `native` representation it is return as it is. If not, it is mapped from
293293
`slim` to `native` and returned as a new `Array2D`.
294+
295+
``xp`` is forwarded from ``self._xp`` rather than left at its ``numpy``
296+
default. A property cannot take an argument, so without this a JAX-backed
297+
array would be re-mapped slim -> native through the NumPy path and raise
298+
``TracerArrayConversionError`` inside a ``jax.jit`` trace.
294299
"""
295300
return Array2D(
296-
values=self, mask=self.mask, header=self.header, store_native=True
301+
values=self,
302+
mask=self.mask,
303+
header=self.header,
304+
store_native=True,
305+
xp=self._xp,
297306
)
298307

299308
@property

0 commit comments

Comments
 (0)