Skip to content

arr_1d[np.sort(idx)] can be much faster (CoordinateIndexer fast path) #4170

Description

@selmanozleyen

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions