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:
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", ())
return
kinda related convo: zarrs/zarrs-python#147 (comment)
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 byCoordinateIndexer.__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 inO(#chunks_touched · log n).arr[idx],vindex[idx], andget_coordinate_selectionall construct aCoordinateIndexer, 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):
Minimal repro (only needs
zarr+numpy):Suggestion
If we added this codeblock around here in the constructor:
zarr-python/src/zarr/core/indexing.py
Line 1208 in ecc2d77
kinda related convo: zarrs/zarrs-python#147 (comment)