Skip to content

Commit 1e054a4

Browse files
committed
feat: serve a sorted integer selection as runs, so it reaches zarrs at all
A sorted integer-array selection is mostly runs of consecutive indices, and a run is a slice. Split into runs, each one is a contiguous box the existing read path already knows how to serve. Without this a fancy index that is not contiguous is declined outright and the whole read falls back to zarr-python's pipeline -- so the shape a row-sampling data loader produces never reached zarrs, however well zarrs could have served it. Reads and writes stop sharing one description function, because they now want different ones. A read is split into runs; a WRITE must not be. Splitting a write can put two items on one chunk key, and the write path is read-modify-write, so two items on one key race. Indices are normalised to int64 first, which two dtypes needed for different reasons. A uint8 selection like [255, 0] differenced to 1, read as consecutive, and returned an empty slice -- wrong data, with no error. And a BOOLEAN MASK is not an index array at all: cast blindly it becomes [1, 1, ...], which is non-decreasing and exactly as long as the output slice, so it passed every test here and read element 1 once per True. A mask's POSITIONS are what it means, so that is what it is turned into -- which also lets a mask take this path rather than falling back.
1 parent 40448f8 commit 1e054a4

4 files changed

Lines changed: 527 additions & 16 deletions

File tree

python/zarrs/pipeline.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
DiscontiguousArrayError,
3030
FillValueNoneError,
3131
UnsupportedVIndexingError,
32-
make_chunk_info_for_rust_with_indices,
32+
chunk_info_for_read,
33+
chunk_info_for_write,
3334
)
3435

3536

@@ -183,9 +184,7 @@ async def read(
183184
if self.impl is None:
184185
raise UnsupportedMetadataError()
185186
self._raise_error_on_unsupported_batch_dtype(batch_info)
186-
chunks_desc = make_chunk_info_for_rust_with_indices(
187-
batch_info, drop_axes, out.shape
188-
)
187+
chunks_desc = chunk_info_for_read(batch_info, drop_axes, out.shape)
189188
except (
190189
UnsupportedMetadataError,
191190
DiscontiguousArrayError,
@@ -218,9 +217,7 @@ async def write(
218217
if self.impl is None:
219218
raise UnsupportedMetadataError()
220219
self._raise_error_on_unsupported_batch_dtype(batch_info)
221-
chunks_desc = make_chunk_info_for_rust_with_indices(
222-
batch_info, drop_axes, value.shape
223-
)
220+
chunks_desc = chunk_info_for_write(batch_info, drop_axes, value.shape)
224221
except (
225222
UnsupportedMetadataError,
226223
DiscontiguousArrayError,

python/zarrs/utils.py

Lines changed: 168 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,18 @@
1212
from zarrs._internal import ChunkItem
1313

1414
if TYPE_CHECKING:
15-
from collections.abc import Iterable
15+
from collections.abc import Iterable, Iterator
1616
from types import EllipsisType
1717

1818
from zarr.abc.store import ByteGetter, ByteSetter
1919
from zarr.core.array_spec import ArraySpec
2020
from zarr.core.indexing import SelectorTuple
2121
from zarr.dtype import ZDType
2222

23+
BatchInfo = Iterable[
24+
tuple[ByteGetter | ByteSetter, ArraySpec, SelectorTuple, SelectorTuple, bool]
25+
]
26+
2327

2428
# adapted from https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor
2529
def get_max_threads() -> int:
@@ -38,6 +42,34 @@ class FillValueNoneError(Exception):
3842
pass
3943

4044

45+
def _as_int64_batch_info(batch_info: BatchInfo) -> BatchInfo:
46+
"""Normalise the batch's array indices to int64 positions, lazily."""
47+
48+
def cast(sel: SelectorTuple) -> SelectorTuple:
49+
if isinstance(sel, np.ndarray):
50+
# A BOOLEAN MASK is not an index array: `zarr`'s `BoolArrayDimIndexer` hands one
51+
# over with a slice out-selection. Cast blindly it becomes [1, 1, ...], which is
52+
# non-decreasing and the right length, so it passes every test below and reads
53+
# element 1 once per True -- silently wrong data. Its positions are what it means.
54+
if sel.dtype.kind == "b":
55+
return np.flatnonzero(sel).astype(np.int64, copy=False)
56+
# Integers and floats only. A `uint64` selection reaches us as float64, because
57+
# zarr subtracts an int64 offset from it, so "f" has to be accepted; anything
58+
# else is not a selection this path can read, and declining is what the caller's
59+
# fallback is for.
60+
if sel.dtype.kind not in "iuf":
61+
raise DiscontiguousArrayError(sel.dtype)
62+
return sel.astype(np.int64, copy=False)
63+
if isinstance(sel, tuple) and any(isinstance(s, np.ndarray) for s in sel):
64+
return tuple(map(cast, sel))
65+
return sel
66+
67+
return (
68+
(byte_getter, chunk_spec, cast(chunk_sel), cast(out_sel), is_complete)
69+
for byte_getter, chunk_spec, chunk_sel, out_sel, is_complete in batch_info
70+
)
71+
72+
4173
# This is a (mostly) copy of the function from zarr.core.indexing that fixes:
4274
# DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated
4375
# TODO: Upstream this fix
@@ -53,10 +85,11 @@ def make_slice_selection(selection: tuple[np.ndarray | float]) -> list[slice]:
5385
slice(int(dim_selection.item()), int(dim_selection.item()) + 1, 1)
5486
)
5587
else:
56-
diff = np.diff(dim_selection)
57-
if (diff != 1).any() and (diff != 0).any():
58-
raise DiscontiguousArrayError(diff)
59-
ls.append(slice(dim_selection[0], dim_selection[-1] + 1, 1))
88+
# int64 (see `_as_int64`): an unsigned diff wraps a decrease into +1.
89+
steps = dim_selection[1:] - dim_selection[:-1]
90+
if (steps != 1).any() and (steps != 0).any():
91+
raise DiscontiguousArrayError(steps)
92+
ls.append(slice(int(dim_selection[0]), int(dim_selection[-1]) + 1, 1))
6093
else:
6194
ls.append(dim_selection)
6295
return ls
@@ -70,6 +103,89 @@ def selector_tuple_to_slice_selection(selector_tuple: SelectorTuple) -> list[sli
70103
return make_slice_selection(selector_tuple)
71104

72105

106+
def _as_selector_tuples(
107+
chunk_selection: SelectorTuple, out_selection: SelectorTuple
108+
) -> tuple[tuple, tuple]:
109+
"""Both selections as tuples, so an axis can be addressed by position."""
110+
return (
111+
chunk_selection if isinstance(chunk_selection, tuple) else (chunk_selection,),
112+
out_selection if isinstance(out_selection, tuple) else (out_selection,),
113+
)
114+
115+
116+
def _is_sorted_integer_axis(indices: Any, out_axis_sel: Any) -> bool:
117+
"""Is this one sorted 1-D integer axis written to a contiguous output slice?"""
118+
# Negative indices and the output length are the caller's to judge: one raises where the
119+
# other declines, so the order of those checks belongs at the call site.
120+
return (
121+
isinstance(indices, np.ndarray)
122+
and indices.ndim == 1
123+
# Non-decreasing only: any decrease would mean one box per element, a decode each.
124+
and not (indices[1:] < indices[:-1]).any()
125+
and isinstance(out_axis_sel, slice)
126+
and out_axis_sel.step in (None, 1)
127+
)
128+
129+
130+
def _output_run_matches(indices: np.ndarray, out_axis_sel: slice) -> bool:
131+
"""Does the output slice hold exactly one element per index."""
132+
start = out_axis_sel.start or 0
133+
return out_axis_sel.stop - start == indices.size
134+
135+
136+
def split_selection_runs(
137+
chunk_selection: SelectorTuple, out_selection: SelectorTuple
138+
) -> Iterator[tuple[SelectorTuple, SelectorTuple]]:
139+
"""Split a selection with one non-consecutive integer-array axis into contiguous boxes.
140+
141+
zarrs describes a chunk read as a rectangular subset, so ``z[[3, 7, 8], :]`` has no
142+
single-box description -- but it is a *stack* of boxes, one per run of consecutive
143+
indices. Only one array axis is split: with two, outer and coordinate indexing disagree
144+
on what the selection means. Anything not splittable is yielded unchanged.
145+
"""
146+
chunk_sel, out_sel = _as_selector_tuples(chunk_selection, out_selection)
147+
unsplit = ((chunk_selection, out_selection),)
148+
149+
array_axes = [
150+
axis for axis, sel in enumerate(chunk_sel) if isinstance(sel, np.ndarray)
151+
]
152+
# Equal arity means no axis was dropped, so chunk axis `axis` is output axis `axis`.
153+
if len(array_axes) != 1 or len(chunk_sel) != len(out_sel):
154+
yield from unsplit
155+
return
156+
(axis,) = array_axes
157+
indices = chunk_sel[axis]
158+
out_axis_sel = out_sel[axis]
159+
if not _is_sorted_integer_axis(indices, out_axis_sel) or not all(
160+
isinstance(sel, slice) for sel in out_sel
161+
):
162+
yield from unsplit
163+
return
164+
# this line can be removed once https://github.com/zarr-developers/zarr-python/issues/4285 is fixed
165+
if (indices < 0).any():
166+
raise DiscontiguousArrayError(indices)
167+
out_start = out_axis_sel.start or 0
168+
if not _output_run_matches(indices, out_axis_sel):
169+
yield from unsplit
170+
return
171+
172+
# Always slices, even for one run: `resulting_shape_from_index` mis-orders a non-leading
173+
# advanced index, and the caller's element-count check then rejects the selection.
174+
boundaries = np.flatnonzero(indices[1:] != indices[:-1] + 1) + 1
175+
176+
for start, stop in zip(
177+
np.concatenate(([0], boundaries)),
178+
np.concatenate((boundaries, [indices.size])),
179+
strict=True,
180+
):
181+
rows = indices[start:stop]
182+
box_chunk_sel = list(chunk_sel)
183+
box_chunk_sel[axis] = slice(int(rows[0]), int(rows[-1]) + 1)
184+
box_out_sel = list(out_sel)
185+
box_out_sel[axis] = slice(out_start + int(start), out_start + int(stop))
186+
yield tuple(box_chunk_sel), tuple(box_out_sel)
187+
188+
73189
def resulting_shape_from_index(
74190
array_shape: tuple[int, ...],
75191
index_tuple: tuple[int | slice | EllipsisType | np.ndarray],
@@ -153,13 +269,56 @@ class RustChunkInfo:
153269
write_empty_chunks: bool
154270

155271

156-
def make_chunk_info_for_rust_with_indices(
157-
batch_info: Iterable[
158-
tuple[ByteGetter | ByteSetter, ArraySpec, SelectorTuple, SelectorTuple, bool]
159-
],
272+
def chunk_info_for_write(
273+
batch_info: BatchInfo,
274+
drop_axes: tuple[int, ...],
275+
shape: tuple[int, ...],
276+
) -> RustChunkInfo:
277+
"""Describe a write batch to Rust, one item per entry.
278+
279+
Neither the chunk-unit grouping nor the run splitting a READ gets: both can put two
280+
items on one chunk key, and the write path is a read-modify-write, so two items on one
281+
key race. A write therefore describes exactly what it was given.
282+
"""
283+
return _chunk_items(_as_int64_batch_info(batch_info), drop_axes, shape)
284+
285+
286+
def chunk_info_for_read(
287+
batch_info: BatchInfo,
288+
drop_axes: tuple[int, ...],
289+
shape: tuple[int, ...],
290+
) -> RustChunkInfo:
291+
"""Describe a read batch to Rust, one box per RUN of consecutive indices.
292+
293+
A sorted integer selection is mostly runs, and a run is a slice. Handing each run over
294+
as a slice is what lets the read reach zarrs at all: a fancy index that is not
295+
contiguous is otherwise declined, and the whole read falls back to zarr-python.
296+
"""
297+
return _chunk_items(
298+
[
299+
(byte_getter, chunk_spec, box_chunk_sel, box_out_sel, is_complete)
300+
for (
301+
byte_getter,
302+
chunk_spec,
303+
chunk_selection,
304+
out_selection,
305+
is_complete,
306+
) in _as_int64_batch_info(batch_info)
307+
for box_chunk_sel, box_out_sel in split_selection_runs(
308+
chunk_selection, out_selection
309+
)
310+
],
311+
drop_axes,
312+
shape,
313+
)
314+
315+
316+
def _chunk_items(
317+
batch_info: BatchInfo,
160318
drop_axes: tuple[int, ...],
161319
shape: tuple[int, ...],
162320
) -> RustChunkInfo:
321+
"""One ChunkItem per batch entry, the description both paths end at."""
163322
is_constant = shape == ()
164323
chunk_info_with_indices: list[ChunkItem] = []
165324
write_empty_chunks: bool = True

tests/test_index_dtype_overflow.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""An index array's dtype must not change which selections are accepted.
2+
3+
Subtracting in the incoming dtype inverts the comparison, because on an unsigned
4+
array a decrease wraps to a large positive step:
5+
6+
np.diff(np.array([255, 0], dtype="uint8")) -> array([1], dtype=uint8)
7+
8+
the most extreme possible decrease reads as consecutive, and the slice built from it,
9+
`slice(255, 0 + 1)`, is empty. uint64 is worse than wrong: mixed with a signed step it
10+
promotes to float64 and loses exactness above 2**53.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import TYPE_CHECKING
16+
17+
import numpy as np
18+
import pytest
19+
import zarr
20+
21+
from zarrs.utils import (
22+
DiscontiguousArrayError,
23+
_as_int64_batch_info,
24+
make_slice_selection,
25+
split_selection_runs,
26+
)
27+
28+
if TYPE_CHECKING:
29+
from pathlib import Path
30+
31+
SETTINGS = {
32+
"codec_pipeline.path": "zarrs.ZarrsCodecPipeline",
33+
# No fallback to hide behind: a selection zarrs cannot serve must raise rather
34+
# than be served correctly by zarr-python and look like a passing test.
35+
"codec_pipeline.strict": True,
36+
}
37+
38+
UNSIGNED = ["uint8", "uint16", "uint32", "uint64"]
39+
40+
41+
@pytest.fixture
42+
def sharded(tmp_path: Path) -> tuple[Path, np.ndarray]:
43+
path = tmp_path / "a.zarr"
44+
values = np.arange(32 * 40, dtype="float32").reshape(32, 40)
45+
array = zarr.create_array(
46+
path,
47+
shape=values.shape,
48+
dtype="float32",
49+
chunks=(4, 5),
50+
shards=(16, 20),
51+
compressors=[zarr.codecs.BloscCodec(cname="lz4")],
52+
)
53+
array[:] = values
54+
return path, values
55+
56+
57+
def test_wraparound_decrease_is_not_consecutive() -> None:
58+
"""[255, 0] as uint8 differences to 1. It is a decrease of 255, not a step of 1.
59+
60+
`make_slice_selection` differences directly, so go through the boundary that normalises:
61+
neither half is a guarantee alone.
62+
"""
63+
selection = (np.array([255, 0], dtype="uint8"),)
64+
((_, _, chunk_selection, _, _),) = _as_int64_batch_info(
65+
[(None, None, selection, selection, True)]
66+
)
67+
with pytest.raises(DiscontiguousArrayError):
68+
make_slice_selection(chunk_selection)
69+
70+
71+
@pytest.mark.parametrize("dtype", UNSIGNED)
72+
def test_consecutive_unsigned_still_collapses(dtype: str) -> None:
73+
"""The fix must not reject what was always valid."""
74+
(result,) = make_slice_selection((np.array([7, 8, 9], dtype=dtype),))
75+
assert result == slice(7, 10, 1)
76+
77+
78+
@pytest.mark.parametrize("dtype", UNSIGNED)
79+
def test_unsigned_rows_read_the_same_as_signed(dtype: str, sharded) -> None:
80+
"""A selection's dtype is not part of its meaning."""
81+
path, values = sharded
82+
rows = [3, 4, 5, 11, 12, 27]
83+
with zarr.config.set(SETTINGS):
84+
array = zarr.open_array(path, mode="r")
85+
unsigned = array[np.array(rows, dtype=dtype), :]
86+
signed = array[np.array(rows, dtype="int64"), :]
87+
np.testing.assert_array_equal(unsigned, values[rows, :])
88+
np.testing.assert_array_equal(unsigned, signed)
89+
90+
91+
@pytest.mark.parametrize("dtype", UNSIGNED)
92+
def test_unsorted_unsigned_is_still_refused(dtype: str, sharded) -> None:
93+
"""Descending rows must not be admitted just because the dtype hides the descent."""
94+
path, _ = sharded
95+
with zarr.config.set(SETTINGS), pytest.raises(DiscontiguousArrayError):
96+
zarr.open_array(path, mode="r")[np.array([27, 3], dtype=dtype), :]
97+
98+
99+
def test_negative_chunk_relative_index_is_refused() -> None:
100+
"""A negative index must never become a slice bound.
101+
102+
Indices are chunk-relative, and zarr miscomputes them for unsigned dtypes -- uint8
103+
`[27, 3]` arrives as `[-13]`. `slice(-13, -12)` is an empty subset, not a row near the end.
104+
"""
105+
with pytest.raises(DiscontiguousArrayError):
106+
list(
107+
split_selection_runs(
108+
(np.array([-13]), slice(0, 20, 1)),
109+
(slice(0, 1), slice(0, 20)),
110+
)
111+
)
112+
113+
114+
def test_sorted_selections_never_produce_a_negative_bound(sharded) -> None:
115+
"""The guard above must not be firing on ordinary reads."""
116+
path, values = sharded
117+
rng = np.random.default_rng(0)
118+
with zarr.config.set(SETTINGS):
119+
array = zarr.open_array(path, mode="r")
120+
for _ in range(50):
121+
rows = np.sort(
122+
rng.choice(values.shape[0], size=rng.integers(1, 8), replace=False)
123+
)
124+
np.testing.assert_array_equal(array[rows, :], values[rows, :])

0 commit comments

Comments
 (0)