Skip to content

Commit 4383ea8

Browse files
author
James Nightingale
authored
Merge pull request #580 from PyAutoLabs/feature/pointsolver-step0-gather
perf(triangles): step-0 Point containment without the (N,3,2) gather (#579)
2 parents 14d6336 + c13b2d7 commit 4383ea8

3 files changed

Lines changed: 507 additions & 11 deletions

File tree

‎autoarray/structures/triangles/array.py‎

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,69 @@
1+
from dataclasses import dataclass
2+
from typing import Optional, Tuple
3+
14
import numpy as np
25

36
from autoarray.structures.triangles.abstract import HEIGHT_FACTOR
47

58
from autoarray.structures.triangles.abstract import AbstractTriangles
9+
from autoarray.structures.triangles.shape import Point
610
from autoarray.structures.triangles.shape import Shape
11+
from autoarray.structures.triangles.shape import _barycentric_contains
712

813
MAX_CONTAINING_SIZE = 15
914

15+
# Private A/B switch for the step-0 containment route (point-source CPU phase 4b,
16+
# PyAutoArray#579). It only affects `ArrayTriangles` built by
17+
# `CoordinateArrayTriangles.with_vertices` on the static initial lattice (those carrying a
18+
# `Step0Layout`) and tested against a `Point`; every other containment keeps the general
19+
# `shape.mask(self.triangles)` path. All routes return bit-identical kept indices:
20+
#
21+
# - "gather": the general path -- pad, ``(N, 3, 2)`` gather, no-op NaN ``where``.
22+
# - "nopad": the ``(N, 3, 2)`` gather without the pad / NaN ``where`` (no index is -1).
23+
# - "components": six ``(N,)`` 1-D gathers, one per vertex component; no ``(N, 3, 2)`` array.
24+
# - "structured": strided slices of the traced vertex table reshaped to its lattice rows; no
25+
# gather at all, the boolean mask is interleaved back to triangle order.
26+
#
27+
# The switch is read at trace time: a jitted function must be re-traced (fresh closure plus
28+
# ``jax.clear_caches()``) after changing it.
29+
_STEP0_CONTAINMENT = "structured"
30+
31+
32+
@dataclass(frozen=True)
33+
class Step0Layout:
34+
"""
35+
The closed-form layout of the static initial lattice's vertex table (see
36+
`autoarray.structures.triangles.coordinate_array.static_lattice_layout`).
37+
38+
Every value is a Python int, so a layout is hashable and can sit in pytree aux data: under
39+
``jit`` / ``vmap`` it is a trace-time constant.
40+
41+
Attributes
42+
----------
43+
n_rows, n_cols
44+
The triangle lattice is ``n_rows x n_cols`` triangles, stored row-major.
45+
grid
46+
``None`` when the vertex table does not follow the closed-form pattern (only the
47+
"gather" / "nopad" / "components" routes then apply), else
48+
``(n_pairs, pair_width, pad, corners)``: the ``(V, 2)`` table padded by ``pad`` rows is a
49+
``(n_pairs, pair_width, 2)`` grid, and ``corners[p][k] = (p0, c0, na, nb)`` says that for
50+
the parity class ``p = 2 * (i % 2) + (j % 2)`` of triangle row ``i`` and column ``j``,
51+
vertex ``k`` of the class's triangle ``(a, b)`` (``i = 2a + i % 2``, ``j = 2b + j % 2``)
52+
is grid entry ``(p0 + a, c0 + b)``.
53+
"""
54+
55+
n_rows: int
56+
n_cols: int
57+
grid: Optional[Tuple] = None
58+
1059

1160
class ArrayTriangles(AbstractTriangles):
1261
def __init__(
1362
self,
1463
indices,
1564
vertices,
1665
max_containing_size=MAX_CONTAINING_SIZE,
66+
step0_layout: Optional[Step0Layout] = None,
1767
**kwargs,
1868
):
1969
"""
@@ -26,10 +76,16 @@ def __init__(
2676
with the three indices of the vertices.
2777
vertices
2878
The vertices of the triangles.
79+
step0_layout
80+
Set only by `CoordinateArrayTriangles.with_vertices` on the static initial lattice:
81+
the static layout of ``indices`` into ``vertices``, which lets `containing_indices`
82+
test a `Point` without materialising the ``(N, 3, 2)`` triangle array (see
83+
`_STEP0_CONTAINMENT`). A plain Python constant (pytree aux data), never traced.
2984
"""
3085
self._indices = indices
3186
self._vertices = vertices
3287
self.max_containing_size = max_containing_size
88+
self.step0_layout = step0_layout
3389

3490
def __len__(self):
3591
return len(self.triangles)
@@ -159,14 +215,84 @@ def containing_indices(self, shape: Shape) -> np.ndarray:
159215
"""
160216
import jax.numpy as jnp
161217

162-
inside = shape.mask(self.triangles)
218+
inside = None
219+
# Exactly `Point`: `Circle`, `Triangle`, `Polygon` and `Square` subclass `Point` but
220+
# override `mask`, so they must keep the general path.
221+
if self.step0_layout is not None and type(shape) is Point:
222+
inside = self._step0_point_mask(shape)
223+
if inside is None:
224+
inside = shape.mask(self.triangles)
163225

164226
return jnp.where(
165227
inside,
166228
size=self.max_containing_size,
167229
fill_value=-1,
168230
)[0]
169231

232+
def _step0_point_mask(self, point: "Point"):
233+
"""
234+
`Point.mask` of the static initial lattice without the general ``(N, 3, 2)`` gather, by
235+
the route `_STEP0_CONTAINMENT` names; ``None`` selects the general path.
236+
237+
Every route feeds `_barycentric_contains` the same six component values the general path
238+
takes from ``self.triangles`` (at step 0 no index is -1, so its NaN ``where`` is a no-op),
239+
in the same operation order, so the mask -- and the kept indices -- are bit-identical.
240+
"""
241+
import jax.numpy as jnp
242+
243+
route = _STEP0_CONTAINMENT
244+
vertices = self.vertices
245+
indices = self.indices
246+
247+
if route == "nopad":
248+
return point.mask(vertices[indices])
249+
250+
if route == "components":
251+
v0 = vertices[:, 0]
252+
v1 = vertices[:, 1]
253+
i0 = indices[:, 0]
254+
i1 = indices[:, 1]
255+
i2 = indices[:, 2]
256+
return _barycentric_contains(
257+
v0[i0], v1[i0], v0[i1], v1[i1], v0[i2], v1[i2], point.x, point.y
258+
)
259+
260+
if route == "structured" and self.step0_layout.grid is not None:
261+
n_rows = self.step0_layout.n_rows
262+
n_cols = self.step0_layout.n_cols
263+
n_pairs, pair_width, pad, corners = self.step0_layout.grid
264+
if pad:
265+
vertices = jnp.pad(vertices, ((0, pad), (0, 0)))
266+
grid = vertices.reshape(n_pairs, pair_width, 2)
267+
268+
half_rows = (n_rows + 1) // 2
269+
half_cols = (n_cols + 1) // 2
270+
271+
classes = []
272+
for corner in corners:
273+
components = []
274+
for p0, c0, na, nb in corner:
275+
block = grid[p0 : p0 + na, c0 : c0 + nb]
276+
components += [block[..., 0], block[..., 1]]
277+
na, nb = corner[0][2], corner[0][3]
278+
mask = _barycentric_contains(*components, point.x, point.y)
279+
classes.append(
280+
jnp.pad(mask, ((0, half_rows - na), (0, half_cols - nb)))
281+
)
282+
283+
# classes[2 * pi + pj][a, b] is triangle (2a + pi, 2b + pj): interleave to row-major.
284+
interleaved = jnp.stack(
285+
(
286+
jnp.stack((classes[0], classes[1]), axis=-1),
287+
jnp.stack((classes[2], classes[3]), axis=-1),
288+
),
289+
axis=1,
290+
).reshape(2 * half_rows, 2 * half_cols)
291+
292+
return interleaved[:n_rows, :n_cols].reshape(-1)
293+
294+
return None
295+
170296
def for_indexes(self, indexes: np.ndarray) -> "ArrayTriangles":
171297
"""
172298
Create a new ArrayTriangles containing indices and vertices corresponding to the given indexes
@@ -311,6 +437,7 @@ def with_vertices(self, vertices: np.ndarray) -> "ArrayTriangles":
311437
indices=self.indices,
312438
vertices=vertices,
313439
max_containing_size=self.max_containing_size,
440+
step0_layout=self.step0_layout,
314441
)
315442

316443
def tree_flatten(self):
@@ -320,7 +447,7 @@ def tree_flatten(self):
320447
return (
321448
self.indices,
322449
self.vertices,
323-
), (self.max_containing_size,)
450+
), (self.max_containing_size, self.step0_layout)
324451

325452
@classmethod
326453
def tree_unflatten(cls, aux_data, children):
@@ -331,6 +458,7 @@ def tree_unflatten(cls, aux_data, children):
331458
indices=children[0],
332459
vertices=children[1],
333460
max_containing_size=aux_data[0],
461+
step0_layout=aux_data[1],
334462
)
335463

336464

‎autoarray/structures/triangles/coordinate_array.py‎

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from autoarray.structures.triangles.abstract import HEIGHT_FACTOR
88
from autoarray.structures.triangles.abstract import AbstractTriangles
99
from autoarray.structures.triangles.array import ArrayTriangles
10+
from autoarray.structures.triangles.array import Step0Layout
1011

1112

1213
def _lattice_coordinates(
@@ -83,24 +84,118 @@ def static_vertex_table(
8384
axis=1,
8485
)
8586

87+
keys = _vertex_keys(coordinates)
88+
89+
_, first, inverse = np.unique(keys, axis=0, return_index=True, return_inverse=True)
90+
91+
vertices = np.ascontiguousarray(triangles.reshape(-1, 2)[first], dtype=np.float64)
92+
indices = np.ascontiguousarray(inverse.reshape(-1, 3))
93+
94+
vertices.setflags(write=False)
95+
indices.setflags(write=False)
96+
97+
return vertices, indices
98+
99+
100+
def _vertex_keys(coordinates: np.ndarray) -> np.ndarray:
101+
"""
102+
The exact integer lattice key of every ``(3N)`` vertex slot, ordered as
103+
`static_vertex_table` orders them (see its docstring).
104+
"""
105+
flip = np.where((coordinates[:, 0] + coordinates[:, 1]) % 2 != 0, -1, 1)[:, None]
86106
offsets = np.array([[0, 1], [1, -1], [-1, -1]])
87-
keys = np.stack(
107+
return np.stack(
88108
(
89109
coordinates[:, None, 0] + flip * offsets[None, :, 0],
90110
2 * coordinates[:, None, 1] + flip * offsets[None, :, 1],
91111
),
92112
axis=-1,
93113
).reshape(-1, 2)
94114

95-
_, first, inverse = np.unique(keys, axis=0, return_index=True, return_inverse=True)
96115

97-
vertices = np.ascontiguousarray(triangles.reshape(-1, 2)[first], dtype=np.float64)
98-
indices = np.ascontiguousarray(inverse.reshape(-1, 3))
116+
@lru_cache(maxsize=32)
117+
def static_lattice_layout(
118+
y_min: float, y_max: float, x_min: float, x_max: float, scale: float
119+
) -> Step0Layout:
120+
"""
121+
The closed-form layout of `static_vertex_table`'s index map, so the step-0 containment can
122+
read each triangle's vertices by strided slicing instead of a gather (PyAutoArray#579).
123+
124+
The lattice's triangles are row-major, ``n_rows x n_cols``. The vertex table is sorted on the
125+
integer key ``(ky, kx)``; key rows alternate between two widths ``W0, W1`` (equal when the
126+
lattice has an odd number of columns), so the ``(V, 2)`` table -- padded by one row when the
127+
number of key rows is odd -- is a ``(n_pairs, W0 + W1, 2)`` grid of row pairs. Splitting the
128+
triangles into the four parity classes of their (row, column) index fixes each class's flip
129+
and the parity of every vertex's key row, so vertex ``k`` of the class's triangle ``(a, b)``
130+
is grid entry ``(p0 + a, c0 + b)``: one contiguous 2-D slice per class and vertex.
131+
132+
The layout is derived from, and checked element-wise against, the actual index map in NumPy:
133+
if any class did not follow the strided pattern, ``grid`` is ``None`` and the structured
134+
route is not used for this geometry. Built once per geometry (cached) and made only of Python
135+
ints, so it is hashable pytree aux data.
136+
"""
137+
vertices, indices = static_vertex_table(y_min, y_max, x_min, x_max, scale)
138+
coordinates = _lattice_coordinates(y_min, y_max, x_min, x_max, scale)
99139

100-
vertices.setflags(write=False)
101-
indices.setflags(write=False)
140+
ys = np.unique(coordinates[:, 0])
141+
xs = np.unique(coordinates[:, 1])
142+
n_rows, n_cols = ys.shape[0], xs.shape[0]
102143

103-
return vertices, indices
144+
grid_coordinates = np.stack(np.meshgrid(ys, xs, indexing="ij"), axis=-1)
145+
if coordinates.shape[0] != n_rows * n_cols or not np.array_equal(
146+
coordinates, grid_coordinates.reshape(-1, 2)
147+
):
148+
return Step0Layout(n_rows=0, n_cols=0, grid=None)
149+
150+
unique_keys = np.unique(_vertex_keys(coordinates), axis=0)
151+
_, row_counts = np.unique(unique_keys[:, 0], return_counts=True)
152+
R = row_counts.shape[0]
153+
W0 = int(row_counts[0])
154+
W1 = int(row_counts[1]) if R > 1 else W0
155+
if (
156+
unique_keys.shape[0] != vertices.shape[0]
157+
or np.any(row_counts[0::2] != W0)
158+
or np.any(row_counts[1::2] != W1)
159+
):
160+
return Step0Layout(n_rows=n_rows, n_cols=n_cols, grid=None)
161+
162+
# Key rows come in pairs of widths (W0, W1). Viewing the table as (n_pairs, W0 + W1, 2) --
163+
# padded by one row when R is odd -- puts vertex (row r, column c) at
164+
# (r // 2, (r % 2) * W0 + c).
165+
pair_width = W0 + W1
166+
n_pairs = (R + 1) // 2
167+
row_start = np.concatenate(([0], np.cumsum(row_counts)[:-1]))
168+
vertex_key_row = np.repeat(np.arange(R), row_counts)
169+
vertex_key_col = np.arange(vertices.shape[0]) - row_start[vertex_key_row]
170+
vertex_pair = (vertex_key_row // 2)[indices].reshape(n_rows, n_cols, 3)
171+
vertex_offset = ((vertex_key_row % 2) * W0 + vertex_key_col)[indices].reshape(
172+
n_rows, n_cols, 3
173+
)
174+
175+
corners = []
176+
for pi in (0, 1):
177+
for pj in (0, 1):
178+
corner = []
179+
for k in range(3):
180+
class_pair = vertex_pair[pi::2, pj::2, k]
181+
class_offset = vertex_offset[pi::2, pj::2, k]
182+
na, nb = class_pair.shape
183+
p0, c0 = int(class_pair[0, 0]), int(class_offset[0, 0])
184+
expected_pair = p0 + np.arange(na)[:, None] + 0 * class_pair
185+
expected_offset = c0 + np.arange(nb)[None, :] + 0 * class_offset
186+
if not (
187+
np.array_equal(class_pair, expected_pair)
188+
and np.array_equal(class_offset, expected_offset)
189+
):
190+
return Step0Layout(n_rows=n_rows, n_cols=n_cols, grid=None)
191+
corner.append((p0, c0, int(na), int(nb)))
192+
corners.append(tuple(corner))
193+
194+
return Step0Layout(
195+
n_rows=n_rows,
196+
n_cols=n_cols,
197+
grid=(n_pairs, pair_width, int(n_pairs * pair_width - vertices.shape[0]), tuple(corners)),
198+
)
104199

105200

106201
class CoordinateArrayTriangles(AbstractTriangles, ABC):
@@ -113,6 +208,7 @@ def __init__(
113208
y_offset: float = 0.0,
114209
flipped: bool = False,
115210
vertex_table: Optional[Tuple[np.ndarray, np.ndarray]] = None,
211+
step0_layout: Optional[Step0Layout] = None,
116212
):
117213
"""
118214
Represents a set of triangles by integer coordinates.
@@ -134,11 +230,17 @@ def __init__(
134230
sets it. Derived lattices (`for_indexes`, `up_sample`, `neighborhood`) do not inherit
135231
it, and it is not part of the pytree (`tree_flatten`), so an unflattened copy falls
136232
back to the flat table -- which is still correct, only unshared.
233+
step0_layout
234+
The `static_lattice_layout` of ``vertex_table``, set with it by
235+
`for_limits_and_scale(..., static_vertices=True)` and passed by `with_vertices` to the
236+
`ArrayTriangles` it returns, whose `containing_indices` then avoids the ``(N, 3, 2)``
237+
gather. Dropped exactly where ``vertex_table`` is.
137238
"""
138239
import jax.numpy as jnp
139240

140241
self.coordinates = coordinates
141242
self.vertex_table = vertex_table
243+
self.step0_layout = step0_layout if vertex_table is not None else None
142244
self.side_length = side_length
143245
self.flipped = flipped
144246

@@ -191,17 +293,25 @@ def for_limits_and_scale(
191293
import jax.numpy as jnp
192294

193295
vertex_table = None
296+
step0_layout = None
194297
if static_vertices:
195-
vertex_table = static_vertex_table(
196-
float(y_min), float(y_max), float(x_min), float(x_max), float(scale)
298+
geometry = (
299+
float(y_min),
300+
float(y_max),
301+
float(x_min),
302+
float(x_max),
303+
float(scale),
197304
)
305+
vertex_table = static_vertex_table(*geometry)
306+
step0_layout = static_lattice_layout(*geometry)
198307

199308
return cls(
200309
coordinates=jnp.array(
201310
_lattice_coordinates(y_min, y_max, x_min, x_max, scale)
202311
),
203312
side_length=scale,
204313
vertex_table=vertex_table,
314+
step0_layout=step0_layout,
205315
)
206316

207317
def tree_flatten(self):
@@ -437,6 +547,7 @@ def with_vertices(self, vertices: np.ndarray) -> ArrayTriangles:
437547
return ArrayTriangles(
438548
indices=self.indices,
439549
vertices=vertices,
550+
step0_layout=self.step0_layout,
440551
)
441552

442553
def for_indexes(self, indexes: np.ndarray) -> "CoordinateArrayTriangles":

0 commit comments

Comments
 (0)