Skip to content

Commit fb8a2a7

Browse files
committed
perf(zarr-indexing): joint chunk enumeration for correlated vindex maps
Candidate-chunk enumeration took the cartesian product of each correlated ArrayMap's per-dimension distinct chunk ids and relied on intersect() to filter untouched combinations. For a diagonal selection of P scattered points that is P**2 intersect calls — quadratic in the number of selected points, the same workload shape as zarr-developers#4174 (400 points: ~2.6s; 10k points: ~30min). Group correlated maps jointly instead: broadcast their per-point chunk ids, take the distinct rows (np.unique(axis=0), O(P log P)), and enumerate exactly the touched combinations. Candidate slots now carry chunk-coordinate tuples covering one or more output dimensions; orthogonal/constant/slice dimensions keep their existing per-dimension candidates. 400-point diagonal resolution drops from 2628ms to 14ms and scales linearly. Assisted-by: ClaudeCode:claude-fable-5
1 parent 9a744c3 commit fb8a2a7

2 files changed

Lines changed: 93 additions & 38 deletions

File tree

packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -138,35 +138,42 @@ def iter_chunk_transforms(
138138
yield from _iter_sorted_1d_array_map(sorted_map, storage, dim_grid)
139139
return
140140

141-
# Enumerate candidate chunks via the cartesian product of per-dimension
142-
# candidate chunk ids, then for each candidate intersect the transform with
143-
# the chunk domain (`transform.intersect` handles orthogonal and vectorized
144-
# cases alike, filtering out combinations it does not actually touch).
141+
# Enumerate candidate chunks via the cartesian product of per-slot candidate
142+
# chunk ids, then for each candidate intersect the transform with the chunk
143+
# domain (`transform.intersect` handles orthogonal and vectorized cases
144+
# alike, filtering out combinations it does not actually touch).
145145
#
146-
# Each dimension contributes exactly the chunk ids it can touch:
146+
# A slot covers one or more output dimensions and contributes exactly the
147+
# chunk-coordinate tuples those dimensions can touch:
147148
#
148-
# - `ConstantMap`/`DimensionMap` dims contribute a contiguous `range` — a
149-
# single chunk for a constant, and the span between the first and last
150-
# chunk for a slice. These are already tight (or nearly so).
151-
# - `ArrayMap` (fancy) dims contribute only the *distinct* chunk ids the
152-
# index array actually lands in (`np.unique`), never the dense
153-
# `range(min_chunk, max_chunk + 1)` between them. A sparse fancy selection
154-
# (e.g. two far-apart coordinates) would otherwise enumerate every chunk
155-
# in the bounding box, making resolution scale with grid size instead of
156-
# with the number of selected coordinates.
157-
#
158-
# For >= 2 correlated (vindex) ArrayMaps the per-dimension distinct sets
159-
# over-approximate the *joint* touched set (their cartesian product includes
160-
# combinations no single point lands in), but `intersect` filters those out,
161-
# so the yielded chunks are identical either way — and the work stays bounded
162-
# by the per-dimension distinct chunk counts, not the grid size.
163-
chunk_candidates: list[Sequence[int]] = []
149+
# - `ConstantMap`/`DimensionMap` dims each form their own slot with a
150+
# contiguous range — a single chunk for a constant, and the span between
151+
# the first and last chunk for a slice. These are already tight (or
152+
# nearly so).
153+
# - Orthogonal `ArrayMap` (fancy) dims each form their own slot with only
154+
# the *distinct* chunk ids the index array actually lands in
155+
# (`np.unique`), never the dense `range(min_chunk, max_chunk + 1)`
156+
# between them. A sparse fancy selection (e.g. two far-apart coordinates)
157+
# would otherwise enumerate every chunk in the bounding box, making
158+
# resolution scale with grid size instead of with the number of selected
159+
# coordinates.
160+
# - Correlated (vindex) `ArrayMap` dims share one *joint* slot holding the
161+
# distinct chunk-coordinate tuples the points actually land in. The
162+
# cartesian product of their per-dimension distinct sets would include
163+
# combinations no point touches — quadratic in the number of selected
164+
# points for a diagonal selection — while the joint distinct set is
165+
# bounded by the point count (see zarr-python gh-4174).
166+
correlated_dims: list[int] = []
167+
correlated_chunk_ids: list[np.ndarray[Any, np.dtype[np.intp]]] = []
168+
slot_dims: list[tuple[int, ...]] = []
169+
slot_candidates: list[Sequence[tuple[int, ...]]] = []
164170
for out_dim, m in enumerate(transform.output):
165171
dg = dim_grids[out_dim]
166172
if isinstance(m, ConstantMap):
167173
# Single chunk
168174
c = dg.index_to_chunk(m.offset)
169-
chunk_candidates.append((c,))
175+
slot_dims.append((out_dim,))
176+
slot_candidates.append(((c,),))
170177
elif isinstance(m, DimensionMap):
171178
d = m.input_dimension
172179
dim_lo = transform.domain.inclusive_min[d]
@@ -181,25 +188,48 @@ def iter_chunk_transforms(
181188
s_max = m.offset + m.stride * dim_lo
182189
first = dg.index_to_chunk(s_min)
183190
last = dg.index_to_chunk(s_max)
184-
chunk_candidates.append(range(first, last + 1))
191+
slot_dims.append((out_dim,))
192+
slot_candidates.append([(c,) for c in range(first, last + 1)])
185193
else:
186194
# m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap).
187195
# Storage coordinates were already computed for a correlated 1-D map.
188196
storage = (
189197
array_map_1d[1] if array_map_1d is not None else m.offset + m.stride * m.index_array
190198
)
191-
flat = storage.ravel().astype(np.intp)
192-
if flat.size == 0:
199+
if storage.size == 0:
193200
# Empty fancy selection: no coordinates, so no chunks are touched.
194201
return
195-
chunk_ids = dg.indices_to_chunks(flat)
196-
# Enumerate only the distinct chunks the coordinates land in.
197-
chunk_candidates.append([int(c) for c in np.unique(chunk_ids)])
202+
# Keep the index-array shape: correlated maps broadcast against each
203+
# other below, and raveling first would lose the singleton axes.
204+
chunk_ids = dg.indices_to_chunks(storage.astype(np.intp))
205+
if m.input_dimension is None:
206+
correlated_dims.append(out_dim)
207+
correlated_chunk_ids.append(chunk_ids)
208+
else:
209+
slot_dims.append((out_dim,))
210+
slot_candidates.append([(int(c),) for c in np.unique(chunk_ids)])
211+
212+
if len(correlated_dims) == 1:
213+
slot_dims.append((correlated_dims[0],))
214+
slot_candidates.append([(int(c),) for c in np.unique(correlated_chunk_ids[0])])
215+
elif len(correlated_dims) >= 2:
216+
# Group the points jointly: distinct rows of the per-point chunk
217+
# coordinates, O(points log points) regardless of grid size.
218+
broadcast = np.broadcast_arrays(*correlated_chunk_ids)
219+
stacked = np.stack([b.ravel() for b in broadcast], axis=1)
220+
joint = np.unique(stacked, axis=0)
221+
slot_dims.append(tuple(correlated_dims))
222+
slot_candidates.append([tuple(int(c) for c in row) for row in joint])
198223

199224
import itertools
200225

201-
for chunk_coords_tuple in itertools.product(*chunk_candidates):
202-
chunk_coords = tuple(int(c) for c in chunk_coords_tuple)
226+
output_rank = len(transform.output)
227+
for combo in itertools.product(*slot_candidates):
228+
chunk_coords_list = [0] * output_rank
229+
for dims, part in zip(slot_dims, combo, strict=True):
230+
for d, c in zip(dims, part, strict=True):
231+
chunk_coords_list[d] = c
232+
chunk_coords = tuple(chunk_coords_list)
203233

204234
# Build the chunk domain in storage space
205235
chunk_min: list[int] = []

packages/zarr-indexing/tests/test_chunk_resolution.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -329,17 +329,16 @@ def test_2d_orthogonal_enumerates_only_touched_chunks(
329329
assert coords == [(0, 0), (0, 999), (999, 0), (999, 999)]
330330
assert calls["n"] == 4
331331

332-
def test_2d_correlated_vindex_enumerates_per_dim_distinct_chunks(
332+
def test_2d_correlated_vindex_enumerates_joint_touched_chunks(
333333
self, monkeypatch: pytest.MonkeyPatch
334334
) -> None:
335335
"""Two correlated (vindex) coordinate arrays scatter to 2 diagonal chunks.
336336
337337
The two points (1, 2) and (3997, 3998) touch chunks (0, 0) and
338-
(999, 999). Per-dimension distinct touched chunks are {0, 999} on each
339-
axis, so enumeration intersects the 2x2 = 4 combinations; the two
340-
off-diagonal combinations are filtered out by `intersect`, leaving 2
341-
surviving chunks. The key guarantee is that the work is bounded by the
342-
per-dimension distinct touched chunks (4), not the dense 1e6 grid.
338+
(999, 999). Correlated coordinate arrays are grouped *jointly*, so
339+
enumeration intersects exactly the 2 touched chunks — never the 2x2
340+
cartesian product of per-dimension distinct chunks, and never the dense
341+
1e6 grid.
343342
"""
344343
grid = ChunkGrid(
345344
dimensions=(
@@ -356,8 +355,34 @@ def test_2d_correlated_vindex_enumerates_per_dim_distinct_chunks(
356355

357356
coords = sorted(r[0] for r in results)
358357
assert coords == [(0, 0), (999, 999)]
359-
# 2x2 per-dim-distinct combinations enumerated; 2 survive intersection.
360-
assert calls["n"] == 4
358+
assert calls["n"] == 2
359+
360+
def test_2d_correlated_vindex_diagonal_is_linear_in_points(
361+
self, monkeypatch: pytest.MonkeyPatch
362+
) -> None:
363+
"""A diagonal of P correlated points touches P chunks with O(P) intersections.
364+
365+
Enumerating the cartesian product of per-dimension distinct chunk sets
366+
would cost P**2 intersections (2500 here) — quadratic in the number of
367+
selected points for the scattered selections of zarr-python gh-4174.
368+
Joint grouping keeps resolution work proportional to the touched chunks.
369+
"""
370+
p = 50
371+
grid = ChunkGrid(
372+
dimensions=(
373+
FixedDimension(size=4, extent=4000),
374+
FixedDimension(size=4, extent=4000),
375+
)
376+
)
377+
# point i lands in chunk (2i, 2i): all per-dimension chunks distinct
378+
coords_1d = np.arange(p, dtype=np.intp) * 8
379+
t = IndexTransform.from_shape((4000, 4000)).vindex[coords_1d, coords_1d]
380+
381+
calls = _count_intersect_calls(monkeypatch)
382+
results = list(iter_chunk_transforms(t, grid._dimensions))
383+
384+
assert sorted(r[0] for r in results) == [(2 * i, 2 * i) for i in range(p)]
385+
assert calls["n"] == p
361386

362387

363388
class TestSubTransformToSelections:

0 commit comments

Comments
 (0)