Repository navigation
arr_1d[np.sort(idx)] can be much faster (CoordinateIndexer fast path) #4170
Description
Describe the new feature you'd like
Hi,
Fancy-indexing a 1-D Zarr array with a sorted integer array (arr[sorted_idx]) is dominated by CoordinateIndexer.__init__. It runs several checks over the flat selection. For a sorted selection this is avoidable: the per-chunk grouping can be computed with a single searchsorted over the touched chunk boundaries in O(#chunks_touched · log n).
arr[idx], vindex[idx], and get_coordinate_selection all construct a CoordinateIndexer, so this covers the natural indexing syntax. And we have batched reads of CSR data stored in zarr for ML training, where each batch is a large sorted gather. And it is definetely a hot path.
Evidence
Index construction only (no I/O), regular sharded 1-D array, ~1450-element runs (table written in md with AI):
| runs | elements | CoordinateIndexer build |
|---|---|---|
| 1024 | 1.48M | 8.7 ms → 0.5 ms |
| 4096 | 5.94M | 35 ms → 2.1 ms |
| 8192 | 11.9M | 71 ms → 4.5 ms |
Minimal repro (only needs zarr + numpy):
import numpy as np, zarr, time
from zarr.core.indexing import CoordinateIndexer
n = 130 * 89_351_824
z = zarr.create_array(
store=zarr.storage.MemoryStore(),
shape=(n,),
chunks=(91_549,),
shards=(89_351_824,),
dtype="f4"
)
cg = z._async_array._chunk_grid
starts = np.sort(np.random.default_rng(0).choice(n // 1450, 4096, replace=False))
coords = np.concatenate([np.arange(s * 1450, (s + 1) * 1450) for s in starts])
t = time.perf_counter()
CoordinateIndexer((coords,), (n,), cg)
print((time.perf_counter() - t) * 1e3, "ms")Suggestion
If we added this codeblock around here in the constructor:
zarr-python/src/zarr/core/indexing.py
Line 1208 in ecc2d77
if (
isinstance(g0, FixedDimension)
and g0.size > 0 # guard the divide below
and coords.ndim == 1
and coords.size > 0
and coords[0] >= 0
and coords[-1] < shape[0]
and bool((np.diff(coords) >= 0).all()) # sorted ascending -> grouped by chunk
):
size = g0.size
first = int(coords[0]) // size
last = int(coords[-1]) // size
# count selected points per chunk in [first, last] via boundary searchsorted
edges = np.arange(first, last + 2, dtype=np.intp) * size
counts = np.diff(np.searchsorted(coords, edges))
chunk_rixs = (first + np.nonzero(counts)[0]).astype(np.intp)
chunk_nitems = np.zeros(nchunks, dtype=np.intp)
chunk_nitems[first : last + 1] = counts
chunk_nitems_cumsum = np.cumsum(chunk_nitems)
object.__setattr__(self, "sel_shape", coords.shape or (1,))
object.__setattr__(self, "selection", (coords,))
object.__setattr__(self, "sel_sort", None)
object.__setattr__(self, "chunk_nitems_cumsum", chunk_nitems_cumsum)
object.__setattr__(self, "chunk_rixs", chunk_rixs)
object.__setattr__(self, "chunk_mixs", (chunk_rixs,))
object.__setattr__(self, "dim_grids", dim_grids)
object.__setattr__(self, "shape", coords.shape or (1,))
object.__setattr__(self, "drop_axes", ())
returnkinda related convo: zarrs/zarrs-python#147 (comment)
Activity
d-v-b commentedon Jul 21, 2026
thanks for this work! if you have the time, could you explore whether the array indexing logic defined in #3906 benefits from the same performance optimization?
selmanozleyen commentedon Jul 21, 2026
I don't know if you mean "if the changes in CoordinateIndexer benefit us" or "if we used the sorted-coordinate grouping trick it would benefit us". But the answer is yes to both!
For ordinary arrays, feat: add IndexTransform library for composable, lazy coordinate mappings #3906 already calls
CoordinateIndexer, so it receives the improvement directly from the newCoordinateIndexer.For lazy arrays, the proposed change is to partition a sorted 1-D
ArrayMaponce usingsearchsorted, rather than scanning the complete coordinate array intransform.intersect(...)for every touched shard.I compared the current #3906 head with the new
CoordinateIndexerand the #3906 after my proposal.All timings measure index planning and shard projection/resolution only; no I/O.
(disclaimer table and dropdown prettification is processed through AI)
CoordinateIndexerSuggested code for #3906:
Results for the lazy transform resolver alone:
Reproducer
Run this from a checkout of #3906:
The ordinary-array measurements were produced by running this benchmark once on #3906 head and once on the new
CoordinateIndexerbranch:d-v-b commentedon Jul 21, 2026
that's great, would you mind opening a PR against my fork, targeting the lazy indexing branch (
refactor/simplify-indexing)? No worries if not, but it would be cool to credit you in the commit historyselmanozleyen commentedon Jul 21, 2026
Yeah ofc I can!
selmanozleyen commentedon Jul 21, 2026
But should I also open a separate PR for the changes CoordinateIndexer?
d-v-b commentedon Jul 21, 2026
yes that would be great, thank you!