Skip to content

Commit 9e9a7d5

Browse files
Jammy2211claude
authored andcommitted
sibson: cut DelaunayNN kernel launches — gated candidate unroll, single concatenated pass, chunk as memory guard
Phase A of #532. On the A100 (post-#531) the DelaunayNN params->H prefix costs 144.8 ms unbatched / 24.4 ms per call at vmap 16 against Delaunay's 7.2 / 5.1 ms. The compiled HLO issues 1,244 kernel launches per 256-query chunk over 95 chunks (~118k launches at ~1.1 us each): the cost is a launch floor, not arithmetic, so this cuts launches instead of flops. - _cavity_triangle_indexes_jax: the 3-trip candidate-edge fori_loop is unrolled at trace time when _sibson_unroll_candidates() is true -- PYAUTO_SIBSON_UNROLL_CANDIDATES ("1"/"0") wins, otherwise jax.default_backend() != "cpu". Unrolling removes ~28% of the launches per chunk (1,244 -> 892) on accelerators; on CPU there is no launch cost to remove and the rolled loop measures ~8-9% faster, so CPU stays rolled. Both paths are bit-identical (same add_candidate calls, edges 0,1,2, same insertion order). - jax_delaunay_nn: the data grid and the 4N split-cross points are located and interpolated in ONE concatenated pass (walk once, Sibson once), sliced at n_query -- the pattern jax_delaunay already uses for its walk. The circumcircles depend only on the frozen simplex table, so they are hoisted and handed in through a new optional circumcircles= kwarg on sibson_mappings_weights_from_tables (default None recomputes as before; jax_sibson and scipy_delaunay_nn are unchanged). - SIBSON_QUERY_CHUNK (default still 256) is overridable at import via PYAUTO_SIBSON_QUERY_CHUNK, validated as a positive int, and picked up by DelaunayNN.query_chunk -- so the chunk can be swept on device without editing source. Documented as a memory guard on the (C, 3, 2) per-cavity intermediates, explicitly not a speed knob: it multiplies a latency-bound program. - Docstrings on the loop structure, the launch-count reasoning and the single-pass invariant. Bit-identity: all 16 jax_delaunay_nn outputs (data and split halves) match a frozen main reference after each step, on the gated path and with the env override forced to 1 and to 0. Chunk invariance proven at 64 / 256 / 1024 through both the kwarg and the env var. Tests: pytest test_autoarray -q -> 1456 passed; 4 new NumPy-only tests (circumcircles-kwarg parity, fixed-seed scipy_delaunay_nn regression, env parsing valid/invalid for both variables, unroll-gate override). black clean. Locally: jax_assertions/delaunay_nn.py exit 0 (relative_l2 9.88e-05, corr 0.9986, max_cavity 11, max_neighbors 13, flip continuity + gradients green) and delaunay_nn_caps.py exit 0. CPU no-regression: interleaved in-process paired A/B, control vs feature ratio 0.993 with the gate (0.927 with the unroll forced on). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5HT8dp7sWc9qDhZp6moGr
1 parent d7c9676 commit 9e9a7d5

2 files changed

Lines changed: 343 additions & 32 deletions

File tree

autoarray/inversion/mesh/interpolator/sibson.py

Lines changed: 184 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
model sample is rejected instead of silently using a truncated stencil.
1212
"""
1313

14+
import os
15+
1416
import numpy as np
1517
from autonerves import cached_property
1618

@@ -33,8 +35,97 @@
3335
# autolens_workspace_test/scripts/misc/jax_assertions/delaunay_nn_caps.py.
3436
SIBSON_MAX_CAVITY_TRIANGLES = 32
3537
SIBSON_MAX_NEIGHBORS = 32
38+
39+
40+
def _positive_int_env(name, raw):
41+
"""Parse a positive-integer environment override; ``None`` when unset."""
42+
if raw is None:
43+
return None
44+
message = f"{name} must be a positive integer, got {raw!r}"
45+
try:
46+
value = int(raw)
47+
except ValueError as error:
48+
raise ValueError(message) from error
49+
if value <= 0:
50+
raise ValueError(message)
51+
return value
52+
53+
54+
def _bool_env(name, raw):
55+
"""Parse a ``"0"``/``"1"`` environment override; ``None`` when unset."""
56+
if raw is None:
57+
return None
58+
if raw not in ("0", "1"):
59+
raise ValueError(f"{name} must be '0' or '1', got {raw!r}")
60+
return raw == "1"
61+
62+
63+
# ``jax.lax.map`` block size for the JAX Sibson query loop, bound onto the mesh
64+
# as ``DelaunayNN.query_chunk``. It is a MEMORY GUARD and nothing else: it
65+
# bounds the ``(C, 3, 2)`` per-query intermediates of the cavity -- the
66+
# inserted Watson circumcircle centres and their contributions, of order
67+
# 15-25 kB per query per lane at cap 32 -- so the live footprint stays bounded
68+
# when the likelihood is vmapped over many live points.
69+
#
70+
# It is emphatically not a speed knob to be kept small. The walk and the two
71+
# cavity loops inside a chunk are latency-bound, so each extra chunk serialises
72+
# another complete set of kernel launches: halving the chunk roughly doubles
73+
# the launch count of the pass (issue #532). Raise it as far as device memory
74+
# allows for the mesh and vmap batch in use.
75+
#
76+
# Set ``PYAUTO_SIBSON_QUERY_CHUNK`` (a positive integer) to override the
77+
# default at import time, so the value can be swept without editing source.
3678
SIBSON_QUERY_CHUNK = 256
3779

80+
_QUERY_CHUNK_OVERRIDE = _positive_int_env(
81+
"PYAUTO_SIBSON_QUERY_CHUNK", os.environ.get("PYAUTO_SIBSON_QUERY_CHUNK")
82+
)
83+
if _QUERY_CHUNK_OVERRIDE is not None:
84+
SIBSON_QUERY_CHUNK = _QUERY_CHUNK_OVERRIDE
85+
86+
# Candidate-edge loop strategy inside the cavity walk
87+
# (:func:`_cavity_triangle_indexes_jax`). The three edges of a cavity triangle
88+
# are either unrolled at trace time or run through a 3-trip ``fori_loop``. The
89+
# two are BIT-IDENTICAL -- the same ``add_candidate`` calls for edges 0, 1, 2 in
90+
# that order, so the same cavity insertion order and therefore the same stencil
91+
# column order and the same floating-point summation order downstream (verified
92+
# against a frozen reference of every ``jax_delaunay_nn`` output). They differ
93+
# only in how the program is emitted, and which is faster is a property of the
94+
# backend, so it is decided at trace time:
95+
#
96+
# * accelerator -- the cavity walk is launch-latency bound, and unrolling the
97+
# inner loop removes ~28% of the kernel launches per chunk (1,244 -> 892 on
98+
# the A100 HST / Hilbert-1500 cell, issue #532), so unroll.
99+
# * CPU -- there is no launch cost to remove, and the unrolled body is three
100+
# times the code inside a 32-trip loop. Measured with an interleaved
101+
# in-process paired A/B of ``jax_delaunay_nn`` (N=1500, Q=17,980 data +
102+
# 6,000 split, fp64, warm median of 25-30 alternating rounds): rolled
103+
# 566.0 / 567.8 ms vs unrolled 614.4 / 614.7 ms, i.e. the rolled loop is
104+
# ~8-9% faster. The same harness reads 0.998 between two copies of
105+
# identical code, so that gap is real, and the standing no-CPU-slowdown
106+
# constraint keeps the loop rolled here.
107+
#
108+
# ``PYAUTO_SIBSON_UNROLL_CANDIDATES`` ("1" or "0") forces one strategy for
109+
# benchmarking; unset means decide from ``jax.default_backend()``.
110+
SIBSON_UNROLL_CANDIDATES = _bool_env(
111+
"PYAUTO_SIBSON_UNROLL_CANDIDATES",
112+
os.environ.get("PYAUTO_SIBSON_UNROLL_CANDIDATES"),
113+
)
114+
115+
116+
def _sibson_unroll_candidates():
117+
"""Whether to unroll the cavity walk's three candidate edges.
118+
119+
``SIBSON_UNROLL_CANDIDATES`` (the environment override) wins when set;
120+
otherwise every backend but CPU unrolls. See the comment above.
121+
"""
122+
if SIBSON_UNROLL_CANDIDATES is not None:
123+
return SIBSON_UNROLL_CANDIDATES
124+
125+
import jax
126+
127+
return jax.default_backend() != "cpu"
128+
38129

39130
def _cross(u, v):
40131
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
@@ -165,6 +256,8 @@ def _cavity_triangle_indexes_jax(
165256
import jax
166257
import jax.numpy as jnp
167258

259+
unroll = _sibson_unroll_candidates()
260+
168261
safe_seed = jnp.maximum(seed_simplex, 0)
169262
cavity = -jnp.ones((max_cavity_triangles,), dtype=jnp.int32)
170263
cavity = cavity.at[0].set(safe_seed)
@@ -198,7 +291,16 @@ def add_candidate(edge, inner_carry):
198291
overflow = overflow | (accepted & ~has_space)
199292
return cavity, count, overflow
200293

201-
return jax.lax.fori_loop(0, 3, add_candidate, (cavity, count, overflow))
294+
# Unrolled or rolled by ``_sibson_unroll_candidates()``; the two are
295+
# bit-identical by construction (same calls, edges 0, 1, 2, same
296+
# order), and the choice is a backend performance question only --
297+
# see the comment on ``SIBSON_UNROLL_CANDIDATES``.
298+
carry = (cavity, count, overflow)
299+
if unroll:
300+
for edge in range(3):
301+
carry = add_candidate(edge, carry)
302+
return carry
303+
return jax.lax.fori_loop(0, 3, add_candidate, carry)
202304

203305
return jax.lax.fori_loop(
204306
0,
@@ -434,10 +536,37 @@ def sibson_mappings_weights_from_tables(
434536
max_cavity_triangles=SIBSON_MAX_CAVITY_TRIANGLES,
435537
max_neighbors=SIBSON_MAX_NEIGHBORS,
436538
query_chunk=SIBSON_QUERY_CHUNK,
539+
circumcircles=None,
437540
xp=np,
438541
):
439542
"""Calculate fixed-shape Sibson mappings and weights from Delaunay tables.
440543
544+
Loop structure (and why the chunk exists)
545+
-----------------------------------------
546+
On the JAX path this is three nested loops: a ``jax.lax.map`` over
547+
``query_chunk``-sized blocks of queries, a ``fori_loop`` of
548+
``max_cavity_triangles`` trips walking the insertion cavity
549+
(:func:`_cavity_triangle_indexes_jax`), and the three candidate edges of
550+
each cavity triangle -- the last of which is unrolled. Only the outer
551+
``lax.map`` is sequential *in the queries*: every chunk re-runs the whole
552+
cavity walk, so the kernel-launch count of one call is roughly
553+
``ceil(Q / query_chunk)`` times the launches of a single chunk. With the
554+
cavity loop dominating that per-chunk count, a small chunk multiplies a
555+
latency-bound program rather than saving time, which is why
556+
``query_chunk`` is documented as a memory guard on the ``(C, 3, 2)``
557+
per-cavity intermediates and nothing else (issue #532). The NumPy path
558+
ignores ``query_chunk`` entirely and loops over queries in Python.
559+
560+
Parameters
561+
----------
562+
circumcircles
563+
Optional precomputed ``(centres, radii_squared, valid)`` triple for
564+
``simplices_padded``, as returned by
565+
:func:`delaunay_circumcircles_from`. They depend only on the frozen
566+
simplex table, so a caller interpolating several query sets against
567+
one mesh (``jax_delaunay_nn``) computes them once and passes them in.
568+
``None`` computes them here, which is what every other caller does.
569+
441570
Returns
442571
-------
443572
mappings, sizes, weights
@@ -447,9 +576,9 @@ def sibson_mappings_weights_from_tables(
447576
Prototype diagnostics. Overflow or a Watson edge degeneracy produces
448577
NaN weights rather than silently returning an approximate stencil.
449578
"""
450-
circumcentres, circumradii_squared, circumcircle_valid = (
451-
delaunay_circumcircles_from(points, simplices_padded, xp=xp)
452-
)
579+
if circumcircles is None:
580+
circumcircles = delaunay_circumcircles_from(points, simplices_padded, xp=xp)
581+
circumcentres, circumradii_squared, circumcircle_valid = circumcircles
453582

454583
def single(query, seed_simplex, outside_fallback_index):
455584
return _sibson_single_from_tables(
@@ -639,38 +768,23 @@ def jax_delaunay_nn(
639768
Qhull returns only fixed-shape integer connectivity through a stopped
640769
``pure_callback``. Point location, circumcircles, Sibson weights, dual
641770
areas, and split-cross coordinates all remain in the JAX graph.
771+
772+
The data grid and the ``4N`` split-cross points are located *and*
773+
interpolated in ONE concatenated pass, the same pattern
774+
:func:`jax_delaunay` uses for its walk. Both the visibility walk and the
775+
Sibson cavity loops are latency-bound, so two passes pay two full sets of
776+
kernel launches while one concatenated pass pays roughly one (issue #532).
777+
The circumcircles of the frozen simplex table do not depend on the
778+
queries, so they are computed once here and handed to
779+
:func:`sibson_mappings_weights_from_tables` instead of being recomputed
780+
per pass. Split points are still seeded at their own nearest vertex and
781+
keep their own outside-hull fallback: concatenation changes only how many
782+
programs run, never a per-query result.
642783
"""
643784
import jax.numpy as jnp
644785

645786
simplices_padded, simplex_neighbors, vertex_simplex = _jax_delaunay_tables(points)
646787

647-
def mappings_weights_for(query):
648-
delaunay_mappings, simplex_indexes = pix_indexes_delaunay_walk_from(
649-
query_points=query,
650-
points=points,
651-
simplices_padded=simplices_padded,
652-
simplex_neighbors=simplex_neighbors,
653-
vertex_simplex=vertex_simplex,
654-
xp=jnp,
655-
return_simplex_indexes=True,
656-
)
657-
return sibson_mappings_weights_from_tables(
658-
query_points=query,
659-
points=points,
660-
simplices_padded=simplices_padded,
661-
simplex_neighbors=simplex_neighbors,
662-
simplex_indexes=simplex_indexes,
663-
outside_fallback_indexes=delaunay_mappings[:, 0],
664-
max_cavity_triangles=max_cavity_triangles,
665-
max_neighbors=max_neighbors,
666-
query_chunk=query_chunk,
667-
xp=jnp,
668-
)
669-
670-
mappings, sizes, weights, cavity_sizes, overflow, degenerate = mappings_weights_for(
671-
query_points
672-
)
673-
674788
valid = simplices_padded[:, 0] >= 0
675789
simplices = simplices_padded.clip(min=0)
676790
p0 = points[simplices[:, 0]]
@@ -689,14 +803,52 @@ def mappings_weights_for(query):
689803
area_weights=areas_factor * jnp.sqrt(areas),
690804
xp=jnp,
691805
)
806+
807+
n_query = query_points.shape[0]
808+
all_query_points = jnp.concatenate([query_points, split_points])
809+
810+
delaunay_mappings, simplex_indexes = pix_indexes_delaunay_walk_from(
811+
query_points=all_query_points,
812+
points=points,
813+
simplices_padded=simplices_padded,
814+
simplex_neighbors=simplex_neighbors,
815+
vertex_simplex=vertex_simplex,
816+
xp=jnp,
817+
return_simplex_indexes=True,
818+
)
819+
820+
circumcircles = delaunay_circumcircles_from(points, simplices_padded, xp=jnp)
821+
822+
outputs = sibson_mappings_weights_from_tables(
823+
query_points=all_query_points,
824+
points=points,
825+
simplices_padded=simplices_padded,
826+
simplex_neighbors=simplex_neighbors,
827+
simplex_indexes=simplex_indexes,
828+
outside_fallback_indexes=delaunay_mappings[:, 0],
829+
max_cavity_triangles=max_cavity_triangles,
830+
max_neighbors=max_neighbors,
831+
query_chunk=query_chunk,
832+
circumcircles=circumcircles,
833+
xp=jnp,
834+
)
835+
836+
(
837+
mappings,
838+
sizes,
839+
weights,
840+
cavity_sizes,
841+
overflow,
842+
degenerate,
843+
) = (output[:n_query] for output in outputs)
692844
(
693845
splitted_mappings,
694846
splitted_sizes,
695847
splitted_weights,
696848
split_cavity_sizes,
697849
split_overflow,
698850
split_degenerate,
699-
) = mappings_weights_for(split_points)
851+
) = (output[n_query:] for output in outputs)
700852

701853
return (
702854
points,

0 commit comments

Comments
 (0)