Skip to content

Commit 2bfa08e

Browse files
committed
perf: windowed numba fast path for the kernel-CDF forward transform (numpy branch)
The RectangularAdaptDensity kernel-CDF forward transform dominates the numba CPU likelihood (euclid 1.66 s = 55% of the eval; hst 27 s = 89%): an O(M x N) blocked erf broadcast with ~126 MB per-block temporaries, rebuilt every evaluation. The numpy branch of F_raw now evaluates each dimension's weighted kernel CDF with a numba kernel over sorted points + weight prefix sums and a +-9-bandwidth saturation window — same values to ~1e-13 (dropped tail terms < 1e-19 of the weight sum), 3.0-3.4x on the step. The blocked numpy implementation remains the JAX branch and the differentiable reference; the sort is internal evaluation order on the gradient-free numpy path only, preserving the module's no-sorts differentiability guarantee where it matters. Validated: autolens_profiling pixelization_numba pins PASS at euclid + hst (euclid eval 3.08 -> 1.17 s, hst ~30 -> 10.1 s on a 4-core container, stacked with the merged #453/#455 wins); test_autoarray 1036 passed (+2 new dense-reference equivalence tests, weighted and unweighted, with out-of-range queries exercising the saturated tails). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcc7MUBMnNU6n8qqS9ioVZ
1 parent 1c33850 commit 2bfa08e

2 files changed

Lines changed: 109 additions & 7 deletions

File tree

autoarray/inversion/mesh/interpolator/rectangular.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,16 @@
3131
constants, gradients flow smoothly through the table values.
3232
"""
3333

34+
import math
35+
3436
import numpy as np
3537
from functools import partial
3638
from typing import Optional
3739

3840
from autonerves import cached_property
3941

42+
from autoarray import numba_util
43+
4044
from autoarray.inversion.mesh.interpolator.abstract import AbstractInterpolator
4145

4246

@@ -84,6 +88,43 @@ def reverse_interp_np(xp, yp, x):
8488

8589
_SQRT2 = np.sqrt(2.0)
8690

91+
# Phi(t) saturates to exactly-representable 0/1 contributions in fp64 well
92+
# inside |t| = 9: the dropped tail terms are < 1e-19 of the weight sum, far
93+
# below the ~1e-13 accumulation noise of the blocked numpy sum the windowed
94+
# numba kernel replaces (measured max deviation 1e-13 on the hst fiducial).
95+
_KERNEL_CDF_SATURATION_T = 9.0
96+
97+
98+
@numba_util.jit()
99+
def _kernel_cdf_dim_windowed(p_sorted, w_sorted, w_prefix, h_d, q, T):
100+
"""
101+
Exact 1D weighted kernel CDF ``F(q) = sum_i w_i Phi((q - p_i) / h)`` for
102+
one dimension, evaluated with a saturation window over sorted points.
103+
104+
Points below ``q - T h`` contribute exactly their weight (prefix sum);
105+
points above ``q + T h`` contribute zero; only the window is summed with
106+
``erfc``. Replaces the O(M x N) blocked numpy broadcast on the numpy path
107+
— same values to ~1e-13 (see ``_KERNEL_CDF_SATURATION_T``) at ~3x the
108+
speed and none of the ~126 MB per-block temporaries; the blocked numpy
109+
implementation remains the JAX-path/differentiable reference.
110+
111+
Sorting note: the module docstring's "no sorts anywhere" invariant is a
112+
JAX-differentiability guarantee. This kernel runs only on the ``xp is
113+
np`` branch, which carries no gradients — the sort is an internal
114+
evaluation order and the returned VALUES are those of the sort-free sum.
115+
"""
116+
out = np.empty(q.shape[0])
117+
inv = 1.0 / (h_d * 1.4142135623730951)
118+
for m in range(q.shape[0]):
119+
qm = q[m]
120+
a = np.searchsorted(p_sorted, qm - T * h_d)
121+
b = np.searchsorted(p_sorted, qm + T * h_d)
122+
acc = w_prefix[a]
123+
for i in range(a, b):
124+
acc += w_sorted[i] * 0.5 * math.erfc((p_sorted[i] - qm) * inv)
125+
out[m] = acc
126+
return out
127+
87128

88129
def _norm_cdf(t, xp):
89130
"""Standard normal CDF, xp-aware (scipy erf on numpy, jax.scipy under jax)."""
@@ -161,15 +202,32 @@ def F_raw(q):
161202
return out.reshape(n_blocks * KERNEL_FORWARD_BLOCK, 2)[:M]
162203

163204
else:
205+
# numpy fast path: per-dimension sorted points + weight prefix sums,
206+
# evaluated by the windowed numba kernel. Same values as the blocked
207+
# broadcast above to ~1e-13; that implementation stays as the JAX
208+
# branch and the differentiable reference.
209+
_p_sorted = []
210+
_w_sorted = []
211+
_w_prefix = []
212+
for _d in range(2):
213+
_order = np.argsort(points[:, _d], kind="stable")
214+
_p_sorted.append(np.ascontiguousarray(np.asarray(points)[_order, _d]))
215+
_w_sorted.append(np.ascontiguousarray(np.asarray(w)[_order]))
216+
_w_prefix.append(np.concatenate([[0.0], np.cumsum(_w_sorted[_d])]))
164217

165218
def F_raw(q):
166-
return np.concatenate(
167-
[
168-
F_raw_block(q[i : i + KERNEL_FORWARD_BLOCK])
169-
for i in range(0, q.shape[0], KERNEL_FORWARD_BLOCK)
170-
],
171-
axis=0,
172-
)
219+
q = np.asarray(q)
220+
out = np.empty_like(q)
221+
for d in range(2):
222+
out[:, d] = _kernel_cdf_dim_windowed(
223+
_p_sorted[d],
224+
_w_sorted[d],
225+
_w_prefix[d],
226+
float(h[d]),
227+
np.ascontiguousarray(q[:, d]),
228+
_KERNEL_CDF_SATURATION_T,
229+
)
230+
return out
173231

174232
# The unit square maps onto the data bounding box exactly (the kernel
175233
# tails outside [lo, hi] are absorbed by the rescale).

test_autoarray/inversion/pixelization/interpolator/test_rectangular.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,47 @@ def __getattr__(self, item):
226226
assert areas.shape == (36,)
227227
assert np.all(np.isfinite(areas))
228228
assert np.all(areas > 0.0)
229+
230+
231+
# ---------------------------------------------------------------------------
232+
# Windowed numba fast path (numpy branch)
233+
# ---------------------------------------------------------------------------
234+
235+
236+
def _dense_reference_forward(data_grid, mesh_pixels, weights, q):
237+
"""The pre-fast-path definition: dense O(M x N) normal-CDF sum, rescaled
238+
so the data bounding box maps onto the unit square, clipped to [0, 1]."""
239+
from scipy.special import erf
240+
241+
points = data_grid
242+
N = points.shape[0]
243+
w = np.full(N, 1.0 / N) if weights is None else weights / weights.sum()
244+
lo, hi = points.min(axis=0), points.max(axis=0)
245+
h = 1.0 * (hi - lo) / mesh_pixels
246+
247+
def F_raw(qq):
248+
t = (qq[:, None, :] - points[None, :, :]) / h[None, None, :]
249+
return np.sum(w[None, :, None] * (0.5 * (1.0 + erf(t / np.sqrt(2.0)))), axis=1)
250+
251+
F_lo = F_raw(lo[None, :])[0]
252+
F_hi = F_raw(hi[None, :])[0]
253+
return np.clip((F_raw(q) - F_lo[None, :]) / (F_hi - F_lo)[None, :], 0.0, 1.0)
254+
255+
256+
@pytest.mark.parametrize("weighted", [False, True])
257+
def test__forward_transform__windowed_numba_matches_dense_reference(weighted):
258+
data_grid, data_grid_over, weights = _seeded_inputs(M=300, K=500, seed=7)
259+
260+
# queries beyond the data bounding box exercise the saturated tails
261+
q = np.concatenate([data_grid_over, data_grid.min(axis=0) - 1.0 + np.zeros((1, 2)),
262+
data_grid.max(axis=0) + 1.0 + np.zeros((1, 2))])
263+
264+
fwd, _ = create_transforms(
265+
data_grid, mesh_pixels=16, mesh_weight_map=weights if weighted else None, xp=np
266+
)
267+
268+
reference = _dense_reference_forward(
269+
data_grid, 16, weights if weighted else None, q
270+
)
271+
272+
np.testing.assert_allclose(fwd(q), reference, rtol=0.0, atol=1e-12)

0 commit comments

Comments
 (0)