Skip to content

Commit 7e74808

Browse files
committed
Vectorize IntArrayDimIndexer
1 parent 41db2dc commit 7e74808

5 files changed

Lines changed: 410 additions & 19 deletions

File tree

src/zarr/core/chunk_grids.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import TYPE_CHECKING, Any, Literal, Self, TypeAlias, TypedDict, Union, cast
1414

1515
import numpy as np
16+
import numpy.typing as npt
1617

1718
import zarr
1819
from zarr.abc.metadata import Metadata
@@ -427,6 +428,28 @@ def array_index_to_chunk_coord(
427428
Coordinates of the chunk containing the array index.
428429
"""
429430

431+
@abstractmethod
432+
def array_indices_to_chunk_dim(
433+
self, array_shape: tuple[int, ...], dim: int, indices: npt.NDArray[np.intp]
434+
) -> npt.NDArray[np.intp]:
435+
"""
436+
Map an array of indices along one dimension to chunk coordinates (vectorized).
437+
438+
Parameters
439+
----------
440+
array_shape : tuple[int, ...]
441+
Shape of the full array.
442+
dim : int
443+
Dimension index.
444+
indices : np.ndarray
445+
Array of indices along the given dimension.
446+
447+
Returns
448+
-------
449+
np.ndarray
450+
Array of chunk coordinates, same shape as indices.
451+
"""
452+
430453
@abstractmethod
431454
def chunks_per_dim(self, array_shape: tuple[int, ...], dim: int) -> int:
432455
"""
@@ -534,6 +557,19 @@ def array_index_to_chunk_coord(
534557
for idx, size in zip(array_index, self.chunk_shape, strict=False)
535558
)
536559

560+
def array_indices_to_chunk_dim(
561+
self, array_shape: tuple[int, ...], dim: int, indices: npt.NDArray[np.intp]
562+
) -> npt.NDArray[np.intp]:
563+
"""
564+
Vectorized mapping of array indices to chunk coordinates along one dimension.
565+
566+
For RegularChunkGrid, this is simply indices // chunk_size.
567+
"""
568+
chunk_size = self.chunk_shape[dim]
569+
if chunk_size == 0:
570+
return np.zeros_like(indices)
571+
return indices // chunk_size
572+
537573
def chunks_per_dim(self, array_shape: tuple[int, ...], dim: int) -> int:
538574
"""
539575
Get the number of chunks along a specific dimension.
@@ -1071,6 +1107,17 @@ def array_index_to_chunk_coord(
10711107

10721108
return tuple(result)
10731109

1110+
def array_indices_to_chunk_dim(
1111+
self, array_shape: tuple[int, ...], dim: int, indices: npt.NDArray[np.intp]
1112+
) -> npt.NDArray[np.intp]:
1113+
"""
1114+
Vectorized mapping of array indices to chunk coordinates along one dimension.
1115+
1116+
For RectilinearChunkGrid, uses np.searchsorted on cumulative sizes.
1117+
"""
1118+
cumsum = np.asarray(self._cumulative_sizes[dim])
1119+
return np.searchsorted(cumsum, indices, side="right").astype(np.intp) - 1
1120+
10741121
def chunks_in_selection(
10751122
self, array_shape: tuple[int, ...], selection: tuple[slice, ...]
10761123
) -> Iterator[tuple[int, ...]]:

src/zarr/core/indexing.py

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -864,12 +864,7 @@ def __init__(
864864
boundscheck_indices(dim_sel, dim_len)
865865

866866
# determine which chunk is needed for each selection item
867-
# Use chunk grid to map each index to its chunk coordinate
868-
dim_sel_chunk = np.empty(len(dim_sel), dtype=np.intp)
869-
for i, idx in enumerate(dim_sel):
870-
full_index = tuple(int(idx) if j == dim else 0 for j in range(len(array_shape)))
871-
chunk_coords = chunk_grid.array_index_to_chunk_coord(array_shape, full_index)
872-
dim_sel_chunk[i] = chunk_coords[dim]
867+
dim_sel_chunk = chunk_grid.array_indices_to_chunk_dim(array_shape, dim, dim_sel)
873868

874869
# determine order of indices
875870
if order == Order.UNKNOWN:
@@ -1315,19 +1310,10 @@ def __init__(
13151310
selection_broadcast = tuple(dim_sel.reshape(-1) for dim_sel in selection_broadcast)
13161311

13171312
# compute chunk index for each point in the selection using chunk grid
1318-
# For each point, we need to find which chunk it belongs to
1319-
npoints = selection_broadcast[0].size
1320-
chunks_multi_index_list = []
1321-
for dim in range(len(shape)):
1322-
dim_chunk_indices = np.empty(npoints, dtype=np.intp)
1323-
for i in range(npoints):
1324-
# Build full coordinate for this point
1325-
point_coords = tuple(int(selection_broadcast[d][i]) for d in range(len(shape)))
1326-
# Map to chunk coordinates
1327-
chunk_coords = chunk_grid.array_index_to_chunk_coord(shape, point_coords)
1328-
dim_chunk_indices[i] = chunk_coords[dim]
1329-
chunks_multi_index_list.append(dim_chunk_indices)
1330-
chunks_multi_index_broadcast = tuple(chunks_multi_index_list)
1313+
chunks_multi_index_broadcast = tuple(
1314+
chunk_grid.array_indices_to_chunk_dim(shape, dim, selection_broadcast[dim])
1315+
for dim in range(len(shape))
1316+
)
13311317

13321318
# ravel chunk indices
13331319
chunks_raveled_indices = np.ravel_multi_index(

tests/test_chunk_grids/test_common.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,52 @@ def test_normalize_chunks_dask_style_irregular_multiple_dims() -> None:
9595

9696
# Should use first chunk size from each dimension
9797
assert result == (10, 20)
98+
99+
100+
# =============================================================================
101+
# Tests for _is_nested_sequence()
102+
# =============================================================================
103+
104+
105+
def test_is_nested_sequence_basic() -> None:
106+
"""Test _is_nested_sequence with typical inputs."""
107+
from zarr.core.chunk_grids import _is_nested_sequence
108+
109+
# Nested sequences → True
110+
assert _is_nested_sequence([[10, 20], [5, 5]]) is True
111+
assert _is_nested_sequence([(10, 20), (5, 5)]) is True
112+
assert _is_nested_sequence(([10, 20], [5, 5])) is True
113+
114+
# Flat sequences → False
115+
assert _is_nested_sequence((10, 10)) is False
116+
assert _is_nested_sequence([10, 10]) is False
117+
118+
119+
def test_is_nested_sequence_non_sequences() -> None:
120+
"""Test _is_nested_sequence with non-sequence types."""
121+
from zarr.core.chunk_grids import _is_nested_sequence
122+
123+
assert _is_nested_sequence(10) is False
124+
assert _is_nested_sequence("auto") is False
125+
assert _is_nested_sequence(None) is False
126+
assert _is_nested_sequence(3.14) is False
127+
128+
129+
def test_is_nested_sequence_chunk_grid_instance() -> None:
130+
"""Test _is_nested_sequence with ChunkGrid instances."""
131+
from zarr.core.chunk_grids import RectilinearChunkGrid, RegularChunkGrid, _is_nested_sequence
132+
133+
assert _is_nested_sequence(RegularChunkGrid(chunk_shape=(10, 10))) is False
134+
assert _is_nested_sequence(RectilinearChunkGrid(chunk_shapes=[[10, 20], [5, 5]])) is False
135+
136+
137+
def test_is_nested_sequence_empty_iterables() -> None:
138+
"""Test _is_nested_sequence with empty iterables.
139+
140+
Empty sequences return False because there's no first element to inspect.
141+
"""
142+
from zarr.core.chunk_grids import _is_nested_sequence
143+
144+
assert _is_nested_sequence([]) is False
145+
assert _is_nested_sequence(()) is False
146+
assert _is_nested_sequence([[]]) is True # outer has one element which is a list

tests/test_chunk_grids/test_rectilinear.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,3 +1804,142 @@ def test_invalid_chunk_coordinates_raise() -> None:
18041804

18051805
with pytest.raises(IndexError):
18061806
grid.get_chunk_start(array_shape, (0, 2)) # Only 2 chunks in dim 1 (0,1)
1807+
1808+
1809+
# ===================================================================
1810+
# End-to-end indexing tests through full zarr Array pipeline
1811+
# (SliceDimIndexer, IntDimIndexer, etc. with rectilinear grids)
1812+
# ===================================================================
1813+
1814+
1815+
def test_slice_at_exact_chunk_boundaries() -> None:
1816+
"""Test slicing exactly at rectilinear chunk boundaries.
1817+
1818+
This exercises the SliceDimIndexer code path end-to-end, verifying
1819+
that the refactored boundary calculation (stop-1 → +1 pattern) is correct.
1820+
Chunk boundaries for rows: [0:10, 10:30, 30:60]
1821+
Chunk boundaries for cols: [0:25, 25:50, 50:75, 75:100]
1822+
"""
1823+
store = MemoryStore()
1824+
data = np.arange(6000, dtype="float64").reshape(60, 100)
1825+
arr = zarr.create_array(
1826+
store=store,
1827+
shape=(60, 100),
1828+
chunks=[[10, 20, 30], [25, 25, 25, 25]],
1829+
dtype="float64",
1830+
zarr_format=3,
1831+
)
1832+
arr[:] = data
1833+
1834+
# Slice exactly one chunk (second row-chunk, first col-chunk)
1835+
np.testing.assert_array_equal(arr[10:30, 0:25], data[10:30, 0:25])
1836+
1837+
# Slice exactly at a boundary start
1838+
np.testing.assert_array_equal(arr[30:60, 75:100], data[30:60, 75:100])
1839+
1840+
# Slice spanning exactly two chunk boundaries
1841+
np.testing.assert_array_equal(arr[0:30, 0:50], data[0:30, 0:50])
1842+
np.testing.assert_array_equal(arr[10:60, 25:75], data[10:60, 25:75])
1843+
1844+
# Single-element slices at each boundary
1845+
np.testing.assert_array_equal(arr[9:10, :], data[9:10, :]) # last row of chunk 0
1846+
np.testing.assert_array_equal(arr[10:11, :], data[10:11, :]) # first row of chunk 1
1847+
np.testing.assert_array_equal(arr[29:30, :], data[29:30, :]) # last row of chunk 1
1848+
np.testing.assert_array_equal(arr[30:31, :], data[30:31, :]) # first row of chunk 2
1849+
1850+
1851+
def test_strided_slicing_rectilinear() -> None:
1852+
"""Test slicing with step > 1 on rectilinear arrays."""
1853+
store = MemoryStore()
1854+
data = np.arange(6000, dtype="float64").reshape(60, 100)
1855+
arr = zarr.create_array(
1856+
store=store,
1857+
shape=(60, 100),
1858+
chunks=[[10, 20, 30], [25, 25, 25, 25]],
1859+
dtype="float64",
1860+
zarr_format=3,
1861+
)
1862+
arr[:] = data
1863+
1864+
np.testing.assert_array_equal(arr[::2, ::3], data[::2, ::3])
1865+
np.testing.assert_array_equal(arr[5:55:5, 10:90:10], data[5:55:5, 10:90:10])
1866+
np.testing.assert_array_equal(arr[::7, ::13], data[::7, ::13])
1867+
1868+
1869+
def test_fancy_indexing_rectilinear() -> None:
1870+
"""Test oindex and vindex on rectilinear arrays through the full Array pipeline."""
1871+
store = MemoryStore()
1872+
data = np.arange(6000, dtype="float64").reshape(60, 100)
1873+
arr = zarr.create_array(
1874+
store=store,
1875+
shape=(60, 100),
1876+
chunks=[[10, 20, 30], [25, 25, 25, 25]],
1877+
dtype="float64",
1878+
zarr_format=3,
1879+
)
1880+
arr[:] = data
1881+
1882+
# oindex: orthogonal indexing with integer arrays spanning multiple chunks
1883+
rows = np.array([0, 9, 10, 29, 30, 59])
1884+
cols = np.array([0, 24, 25, 49, 50, 99])
1885+
np.testing.assert_array_equal(arr.oindex[rows, cols], data[np.ix_(rows, cols)])
1886+
1887+
# vindex: coordinate-based indexing
1888+
r = np.array([0, 10, 30, 59])
1889+
c = np.array([0, 25, 50, 99])
1890+
np.testing.assert_array_equal(arr.vindex[r, c], data[r, c])
1891+
1892+
# oindex with slice on one axis, int array on other
1893+
np.testing.assert_array_equal(arr.oindex[rows, 50:75], data[np.ix_(rows, np.arange(50, 75))])
1894+
np.testing.assert_array_equal(arr.oindex[10:30, cols], data[np.ix_(np.arange(10, 30), cols)])
1895+
1896+
1897+
def test_boolean_mask_indexing_rectilinear() -> None:
1898+
"""Test boolean mask indexing on rectilinear arrays."""
1899+
store = MemoryStore()
1900+
data = np.arange(6000, dtype="float64").reshape(60, 100)
1901+
arr = zarr.create_array(
1902+
store=store,
1903+
shape=(60, 100),
1904+
chunks=[[10, 20, 30], [25, 25, 25, 25]],
1905+
dtype="float64",
1906+
zarr_format=3,
1907+
)
1908+
arr[:] = data
1909+
1910+
# Full 2D boolean mask
1911+
mask = data > 3000
1912+
np.testing.assert_array_equal(arr.vindex[mask], data[mask])
1913+
1914+
# 1D boolean mask on rows via oindex
1915+
row_mask = np.zeros(60, dtype=bool)
1916+
row_mask[[0, 9, 10, 29, 30, 59]] = True # hits all chunk boundaries
1917+
np.testing.assert_array_equal(arr.oindex[row_mask, :], data[row_mask, :])
1918+
1919+
# 1D boolean mask on cols via oindex
1920+
col_mask = np.zeros(100, dtype=bool)
1921+
col_mask[[0, 24, 25, 49, 50, 99]] = True # hits all chunk boundaries
1922+
np.testing.assert_array_equal(arr.oindex[:, col_mask], data[:, col_mask])
1923+
1924+
1925+
def test_block_indexing_rectilinear() -> None:
1926+
"""Test block (chunk-level) indexing on rectilinear arrays."""
1927+
store = MemoryStore()
1928+
data = np.arange(6000, dtype="float64").reshape(60, 100)
1929+
arr = zarr.create_array(
1930+
store=store,
1931+
shape=(60, 100),
1932+
chunks=[[10, 20, 30], [25, 25, 25, 25]],
1933+
dtype="float64",
1934+
zarr_format=3,
1935+
)
1936+
arr[:] = data
1937+
1938+
# Individual blocks
1939+
np.testing.assert_array_equal(arr.blocks[0, 0], data[0:10, 0:25])
1940+
np.testing.assert_array_equal(arr.blocks[1, 0], data[10:30, 0:25])
1941+
np.testing.assert_array_equal(arr.blocks[2, 3], data[30:60, 75:100])
1942+
1943+
# Block range
1944+
np.testing.assert_array_equal(arr.blocks[0:2, 0:2], data[0:30, 0:50])
1945+
np.testing.assert_array_equal(arr.blocks[1:3, 2:4], data[10:60, 50:100])

0 commit comments

Comments
 (0)