Skip to content

Commit f7073c3

Browse files
authored
Merge branch 'main' into dependabot/github_actions/actions-c8837d376d
2 parents 80f1dab + a82522b commit f7073c3

6 files changed

Lines changed: 280 additions & 300 deletions

File tree

‎changes/4392.misc.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Moved the early-exit `fill_value` scan from the `NDBuffer` base class to the CPU buffer, so GPU buffers keep a single reduction.

‎pyproject.toml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ requires-python = ">=3.12"
4747
dependencies = [
4848
'packaging>=22.0',
4949
'numpy>=2',
50-
'numcodecs>=0.14',
50+
'numcodecs>=0.16',
5151
'google-crc32c>=1.5',
5252
'typing_extensions>=4.14',
5353
'donfig>=0.8',
@@ -109,19 +109,19 @@ homepage = "https://github.com/zarr-developers/zarr-python"
109109
# pins deliberately, e.g. via dependabot or `uv lock --upgrade`.
110110
[dependency-groups]
111111
test = [
112-
"coverage==7.16.0",
112+
"coverage==7.16.1",
113113
"pytest==9.1.1",
114114
"pytest-asyncio==1.4.0",
115115
"pytest-cov==7.1.0",
116116
"pytest-accept==0.3.0",
117-
"numpydoc==1.10.0",
117+
"numpydoc==1.11.0",
118118
"hypothesis==6.168.0",
119119
"pytest-reportlog==1.0.0",
120120
"pytest-xdist==3.8.0",
121121
"pytest-benchmark==5.3.0",
122122
"pytest-codspeed==5.0.3",
123123
"tomlkit==0.15.1",
124-
"uv==0.12.12",
124+
"uv==0.12.15",
125125
]
126126
remote-tests = [
127127
{include-group = "test"},
@@ -145,7 +145,7 @@ docs = [
145145
"mkdocs-redirects==1.2.3",
146146
"markdown-exec[ansi]==1.12.3",
147147
"griffe-inherited-docstrings==1.1.3",
148-
"ruff==0.16.6",
148+
"ruff==0.16.8",
149149
# Changelog generation
150150
{include-group = "release"},
151151
# Optional dependencies to run examples
@@ -252,7 +252,7 @@ dependency-groups = ["remote-tests"]
252252
extra-dependencies = [
253253
'packaging==22.*',
254254
'numpy==2.0.*',
255-
'numcodecs==0.14.*', # 0.14 needed for zarr3 codecs
255+
'numcodecs==0.16.*',
256256
'fsspec==2023.10.0',
257257
's3fs==2023.10.0',
258258
'universal_pathlib==0.2.0',

‎src/zarr/core/buffer/core.py‎

Lines changed: 30 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@
3232
# itemsize. A view to a dtype of the same width is always permitted by numpy.
3333
_BITWISE_DTYPE = {1: "u1", 2: "u2", 4: "u4", 8: "u8"}
3434

35-
# Elements in the first slice examined by `NDBuffer.all_equal`. Large enough to
36-
# amortise one numpy call, small enough that a mismatch in it is nearly free.
37-
_ALL_EQUAL_BLOCK = 1 << 14
38-
3935

4036
@runtime_checkable
4137
class ArrayLike(Protocol):
@@ -540,65 +536,12 @@ def __len__(self) -> int:
540536
def __repr__(self) -> str:
541537
return f"<NDBuffer shape={self.shape} dtype={self.dtype} {self._data!r}>"
542538

543-
@staticmethod
544-
def _compare_all(data: NDArrayLike, other: Any, equal_nan: bool) -> bool:
545-
"""Whether every element of `data` equals `other`, inspecting all of it."""
546-
# Handle positive and negative zero by comparing bit patterns:
547-
if (
548-
np.asarray(other).dtype.kind == "f"
549-
and other == 0.0
550-
and data.dtype.kind not in ("U", "S", "T", "O", "V")
551-
):
552-
_data, other = np.broadcast_arrays(data, np.asarray(other, data.dtype))
553-
# Read the bytes as unsigned integers rather than as a void dtype.
554-
# Both compare bit patterns, so both keep -0.0 distinct from 0.0,
555-
# but numpy has vectorised integer comparison loops and no void
556-
# one: the void form falls back to a generic elementwise path that
557-
# is more than an order of magnitude slower. Widths with no integer
558-
# of the same size (longdouble, complex128) keep the void form.
559-
bitwise_dtype = _BITWISE_DTYPE.get(_data.dtype.itemsize, f"V{_data.dtype.itemsize}")
560-
return bool(np.array_equal(_data.view(bitwise_dtype), other.view(bitwise_dtype)))
561-
# use array_equal to obtain equal_nan=True functionality
562-
_data, other = np.broadcast_arrays(data, other)
563-
return bool(
564-
np.array_equal(
565-
_data,
566-
other,
567-
equal_nan=equal_nan if data.dtype.kind not in ("U", "S", "T", "O", "V") else False,
568-
)
569-
)
570-
571539
def all_equal(self, other: Any, equal_nan: bool = True) -> bool:
572-
"""Compare to `other` using np.array_equal."""
540+
"""Whether every element of this buffer equals `other`."""
573541
if other is None:
574542
# Handle None fill_value for Zarr V2
575543
return False
576-
data = self._data
577-
# A buffer that is not uniformly `other` still has to be read in full
578-
# before a whole-buffer comparison can report the first mismatch, and
579-
# on the write path that is the common case. Walking it in slices of
580-
# the leading axis returns as soon as one slice differs. Slices double
581-
# in length, so a buffer that really is uniform is covered in O(log n)
582-
# comparisons and costs the same as the single scan this replaces.
583-
#
584-
# The leading axis is used rather than a flattened view because a chunk
585-
# is usually a strided view into a larger array, which cannot be
586-
# flattened without copying it. Slicing axis 0 is a view whatever the
587-
# layout.
588-
if data.ndim > 0 and np.ndim(other) == 0 and data.size > _ALL_EQUAL_BLOCK:
589-
leading = data.shape[0]
590-
per_index = data.size // leading if leading else 0
591-
if leading > 1 and per_index:
592-
step = max(1, _ALL_EQUAL_BLOCK // per_index)
593-
start = 0
594-
while start < leading:
595-
stop = min(start + step, leading)
596-
if not self._compare_all(data[start:stop], other, equal_nan):
597-
return False
598-
start = stop
599-
step *= 2
600-
return True
601-
return self._compare_all(data, other, equal_nan)
544+
return _array_all_equal(self._data, other, equal_nan)
602545

603546
def fill(self, value: Any) -> None:
604547
self._data.fill(value)
@@ -610,6 +553,34 @@ def transpose(self, axes: SupportsIndex | Sequence[SupportsIndex] | None) -> Sel
610553
return self.__class__(self._data.transpose(axes))
611554

612555

556+
def _array_all_equal(data: NDArrayLike, other: Any, equal_nan: bool) -> bool:
557+
"""Whether every element of `data` equals `other`, inspecting all of it."""
558+
# Handle positive and negative zero by comparing bit patterns:
559+
if (
560+
np.asarray(other).dtype.kind == "f"
561+
and other == 0.0
562+
and data.dtype.kind not in ("U", "S", "T", "O", "V")
563+
):
564+
_data, other = np.broadcast_arrays(data, np.asarray(other, data.dtype))
565+
# Read the bytes as unsigned integers rather than as a void dtype.
566+
# Both compare bit patterns, so both keep -0.0 distinct from 0.0,
567+
# but numpy has vectorised integer comparison loops and no void
568+
# one: the void form falls back to a generic elementwise path that
569+
# is more than an order of magnitude slower. Widths with no integer
570+
# of the same size (longdouble, complex128) keep the void form.
571+
bitwise_dtype = _BITWISE_DTYPE.get(_data.dtype.itemsize, f"V{_data.dtype.itemsize}")
572+
return bool(np.array_equal(_data.view(bitwise_dtype), other.view(bitwise_dtype)))
573+
# use array_equal to obtain equal_nan=True functionality
574+
_data, other = np.broadcast_arrays(data, other)
575+
return bool(
576+
np.array_equal(
577+
_data,
578+
other,
579+
equal_nan=equal_nan if data.dtype.kind not in ("U", "S", "T", "O", "V") else False,
580+
)
581+
)
582+
583+
613584
class BufferPrototype(NamedTuple):
614585
"""Prototype of the Buffer and NDBuffer class
615586

‎src/zarr/core/buffer/cpu.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
from zarr.core.buffer.core import ArrayLike, NDArrayLike
2323
from zarr.core.common import BytesLike
2424

25+
# Elements in the first slice examined by `NDBuffer.all_equal`. Large enough to
26+
# amortise one numpy call, small enough that a mismatch in it is nearly free.
27+
_ALL_EQUAL_BLOCK = 1 << 14
28+
2529

2630
class Buffer(core.Buffer):
2731
"""A flat contiguous memory block
@@ -189,6 +193,42 @@ def __setitem__(self, key: Any, value: Any) -> None:
189193
value = value._data
190194
self._data.__setitem__(key, value)
191195

196+
def all_equal(self, other: Any, equal_nan: bool = True) -> bool:
197+
"""Whether every element of this buffer equals `other`.
198+
199+
Returns as soon as part of the buffer is found to differ, rather than
200+
reading all of it.
201+
"""
202+
if other is None:
203+
# Handle None fill_value for Zarr V2
204+
return False
205+
data = self._data
206+
if data.size == 0 or data.ndim == 0 or np.ndim(other) > 0:
207+
# nothing to slice, or `other` has to broadcast against all of `data`
208+
return core._array_all_equal(data, other, equal_nan)
209+
# A buffer that is not uniformly `other` still has to be read in full
210+
# before a whole-buffer comparison can report the first mismatch, and
211+
# on the write path that is the common case. Walking it in slices of
212+
# the leading axis returns as soon as one slice differs. Slices double
213+
# in length, so a buffer that really is uniform is covered in O(log n)
214+
# comparisons and costs the same as a single scan. A buffer no larger
215+
# than one block is covered by the first slice.
216+
#
217+
# The leading axis is used rather than a flattened view because a chunk
218+
# is usually a strided view into a larger array, which cannot be
219+
# flattened without copying it. Slicing axis 0 is a view whatever the
220+
# layout.
221+
leading = data.shape[0]
222+
step = max(1, _ALL_EQUAL_BLOCK // (data.size // leading))
223+
start = 0
224+
while start < leading:
225+
stop = min(start + step, leading)
226+
if not core._array_all_equal(data[start:stop], other, equal_nan):
227+
return False
228+
start = stop
229+
step *= 2
230+
return True
231+
192232

193233
def as_numpy_array_wrapper(
194234
func: Callable[[npt.NDArray[Any]], bytes], buf: core.Buffer, prototype: core.BufferPrototype

‎tests/test_buffer.py‎

Lines changed: 51 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -240,91 +240,59 @@ def test_empty(
240240
assert result.flags.f_contiguous # type: ignore[attr-defined]
241241

242242

243-
@pytest.mark.parametrize("dtype", ["float16", "float32", "float64", "complex64", "int32"])
244-
@pytest.mark.parametrize("order", ["C", "F"])
245-
def test_all_equal_matches_full_scan(dtype: str, order: str) -> None:
246-
"""The blocked scan must agree with a whole-buffer comparison.
247-
248-
Buffers are deliberately larger than one block and not a whole number of
249-
blocks, and the mismatching element is placed at the very end so an
250-
early-exit bug cannot pass by luck.
251-
"""
252-
n = (1 << 14) * 3 + 7
253-
side = int(np.sqrt(n))
254-
for base in (np.zeros(n, dtype=dtype), np.zeros((side, side), dtype=dtype)):
255-
arr = np.asfortranarray(base) if order == "F" and base.ndim > 1 else base
256-
uniform = cpu.NDBuffer.from_numpy_array(arr)
257-
differs = arr.copy()
258-
differs.reshape(-1)[-1] = 1
259-
mixed = cpu.NDBuffer.from_numpy_array(differs)
260-
for fill in (0, 0.0, 1):
261-
assert uniform.all_equal(fill) == cpu.NDBuffer._compare_all(arr, fill, True)
262-
assert mixed.all_equal(fill) == cpu.NDBuffer._compare_all(differs, fill, True)
243+
def _all_equal_reference(data: np.ndarray, fill: float) -> bool:
244+
"""`NDBuffer.all_equal` as it was before the fast paths: one full scan,
245+
comparing a zero `fill` bitwise through a void view."""
246+
if np.asarray(fill).dtype.kind == "f" and fill == 0.0:
247+
void = f"V{data.dtype.itemsize}"
248+
expected = np.broadcast_to(np.asarray(fill, data.dtype), data.shape)
249+
return bool(np.array_equal(data.view(void), expected.view(void)))
250+
return bool(np.array_equal(data, np.broadcast_to(fill, data.shape), equal_nan=True))
251+
252+
253+
_BLOCK = 1 << 14
254+
255+
256+
def _layouts(dtype: str) -> dict[str, np.ndarray]:
257+
"""Buffers larger than one scan block and not a whole number of blocks."""
258+
side = 211
259+
return {
260+
"1d": np.zeros(_BLOCK * 3 + 7, dtype=dtype),
261+
"C": np.zeros((side, side), dtype=dtype),
262+
"F": np.zeros((side, side), dtype=dtype, order="F"),
263+
# a chunk carved out of a larger array, which is what the write path passes
264+
"strided": np.zeros((2 * side, 2 * side), dtype=dtype)[5 : side + 5, 7 : side + 7],
265+
"leading-1": np.zeros((1, _BLOCK * 2), dtype=dtype),
266+
}
267+
268+
269+
@pytest.mark.parametrize(
270+
"dtype", ["float16", "float32", "float64", "complex64", "longdouble", "int32"]
271+
)
272+
@pytest.mark.parametrize("layout", ["1d", "C", "F", "strided", "leading-1"])
273+
@pytest.mark.parametrize("contents", [0.0, -0.0, np.nan, 1.0])
274+
@pytest.mark.parametrize("mismatch", [None, "first", "last"])
275+
@pytest.mark.parametrize("fill", [0.0, -0.0, np.nan, 1.0, 0])
276+
def test_all_equal(
277+
dtype: str, layout: str, contents: float, mismatch: str | None, fill: float
278+
) -> None:
279+
"""`all_equal` agrees with a full scan, whatever the layout or where a mismatch sits."""
280+
data = _layouts(dtype)[layout]
281+
if np.dtype(dtype).kind == "i" and contents != 1.0:
282+
contents = 0
283+
data[...] = contents
284+
if mismatch is not None:
285+
index = tuple(0 if mismatch == "first" else n - 1 for n in data.shape)
286+
data[index] = 7
287+
assert cpu.NDBuffer.from_numpy_array(data).all_equal(fill) == _all_equal_reference(data, fill)
263288

264289

265290
@pytest.mark.parametrize("dtype", ["float16", "float32", "float64"])
266291
def test_all_equal_distinguishes_negative_zero(dtype: str) -> None:
267292
"""Regression test for #3144: -0.0 is not the same chunk as 0.0."""
268-
n = (1 << 14) * 2 + 3
269-
negative = cpu.NDBuffer.from_numpy_array(np.full(n, -0.0, dtype=dtype))
270-
positive = cpu.NDBuffer.from_numpy_array(np.zeros(n, dtype=dtype))
271-
assert positive.all_equal(0.0)
272-
assert not negative.all_equal(0.0)
273-
assert negative.all_equal(-0.0)
274-
assert not positive.all_equal(-0.0)
275-
# a single -0.0 in an otherwise +0.0 buffer, past the first block
276-
mixed_data = np.zeros(n, dtype=dtype)
277-
mixed_data[-1] = np.array(-0.0, dtype=dtype)
278-
assert not cpu.NDBuffer.from_numpy_array(mixed_data).all_equal(0.0)
279-
280-
281-
def test_all_equal_non_contiguous() -> None:
282-
"""A strided buffer cannot be flattened without copying; it must still be correct."""
283-
n = (1 << 14) * 4
284-
base = np.zeros(n * 2, dtype="float32")
285-
strided = base[::2]
286-
assert not strided.flags.c_contiguous
287-
assert cpu.NDBuffer.from_numpy_array(strided).all_equal(0.0)
288-
strided2 = base[::2].copy()
289-
strided2[-1] = 1
290-
assert not cpu.NDBuffer.from_numpy_array(strided2).all_equal(0.0)
291-
292-
293-
@pytest.mark.parametrize("fill", [0.0, np.nan, 1.0])
294-
def test_all_equal_strided_chunk_view(fill: float) -> None:
295-
"""A chunk carved out of a larger array is strided, which is the shape the
296-
write path actually hands this method. Slicing the leading axis has to work
297-
for those, not just for contiguous buffers."""
298-
whole = np.full((512, 512), fill, dtype="float32")
299-
chunk = whole[64:192, 64:192]
300-
assert not chunk.flags.c_contiguous
301-
assert not chunk.flags.f_contiguous
302-
assert cpu.NDBuffer.from_numpy_array(chunk).all_equal(fill)
303-
# a single differing element, placed last so an early exit cannot pass by luck
304-
whole2 = whole.copy()
305-
whole2[191, 191] = 12345.0
306-
chunk2 = whole2[64:192, 64:192]
307-
assert not cpu.NDBuffer.from_numpy_array(chunk2).all_equal(fill)
308-
# and placed first
309-
whole3 = whole.copy()
310-
whole3[64, 64] = 12345.0
311-
assert not cpu.NDBuffer.from_numpy_array(whole3[64:192, 64:192]).all_equal(fill)
312-
313-
314-
def test_all_equal_single_leading_row() -> None:
315-
"""shape[0] == 1 has no leading axis to slice; it must fall back, not break."""
316-
row = np.zeros((1, (1 << 14) * 2), dtype="float32")
317-
assert cpu.NDBuffer.from_numpy_array(row).all_equal(0.0)
318-
row2 = row.copy()
319-
row2[0, -1] = 1
320-
assert not cpu.NDBuffer.from_numpy_array(row2).all_equal(0.0)
321-
322-
323-
def test_all_equal_nan() -> None:
324-
n = (1 << 14) * 2 + 1
325-
nans = cpu.NDBuffer.from_numpy_array(np.full(n, np.nan, dtype="float64"))
326-
assert nans.all_equal(np.nan)
327-
assert not nans.all_equal(0.0)
328-
one_nan = np.zeros(n, dtype="float64")
329-
one_nan[-1] = np.nan
330-
assert not cpu.NDBuffer.from_numpy_array(one_nan).all_equal(0.0)
293+
positive = np.zeros(_BLOCK * 2 + 3, dtype=dtype)
294+
negative = np.full_like(positive, -0.0)
295+
assert cpu.NDBuffer.from_numpy_array(positive).all_equal(0.0)
296+
assert not cpu.NDBuffer.from_numpy_array(negative).all_equal(0.0)
297+
assert cpu.NDBuffer.from_numpy_array(negative).all_equal(-0.0)
298+
assert not cpu.NDBuffer.from_numpy_array(positive).all_equal(-0.0)

0 commit comments

Comments
 (0)