Skip to content

Commit 260f89b

Browse files
committed
Merge branch 'main' of github.com:zarr-developers/zarr-python
2 parents d28eff7 + ed60e13 commit 260f89b

15 files changed

Lines changed: 456 additions & 168 deletions

File tree

changes/3417.bugfix.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fixed `BytesCodec.from_dict` so that `BytesCodec` instances roundtrip to / from
2+
their dict representation. `BytesCodec.from_dict` now interprets a missing
3+
`endian` configuration as `endian=None` (matching what `BytesCodec.to_dict`
4+
emits), instead of falling back to the system's native byte order.

changes/3987.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Two new fields on `ArrayConfig` control how the sharding codec coalesces partial-shard reads: `sharding_coalesce_max_gap_bytes` (default 1 MiB) and `sharding_coalesce_max_bytes` (default 16 MiB). When reading multiple chunks from the same shard, nearby byte ranges are merged into a single request to the store if separated by no more than `sharding_coalesce_max_gap_bytes` and the merged read stays within `sharding_coalesce_max_bytes`. Defaults are seeded from the matching `array.sharding_coalesce_max_gap_bytes` / `array.sharding_coalesce_max_bytes` keys in [`zarr.config`][] at array-creation time, and can be overridden per array by passing `config={...}` to [`zarr.create_array`][].

docs/user-guide/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Configuration options include the following:
3535
- Async and threading options, e.g. `async.concurrency` and `threading.max_workers`
3636
- Selections of implementations of codecs, codec pipelines and buffers
3737
- Enabling GPU support with `zarr.config.enable_gpu()`. See GPU support for more.
38+
- Control request merging when reading multiple chunks from the same shard with `array.sharding_coalesce_max_gap_bytes` and `array.sharding_coalesce_max_bytes`. Reads of nearby chunks are coalesced into a single request to the store when separated by at most `sharding_coalesce_max_gap_bytes` and the resulting merged read is no larger than `sharding_coalesce_max_bytes`.
3839

3940
For selecting custom implementations of codecs, pipelines, buffers and ndbuffers,
4041
first register the implementations in the registry and then select them in the config.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ test = [
105105
"pytest-benchmark==5.2.3",
106106
"pytest-codspeed==5.0.3",
107107
"tomlkit==0.15.0",
108-
"uv==0.11.19",
108+
"uv==0.11.20",
109109
]
110110
remote-tests = [
111111
{include-group = "test"},

src/zarr/codecs/bytes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def from_dict(cls, data: dict[str, JSON]) -> Self:
6262
data, "bytes", require_configuration=False
6363
)
6464
configuration_parsed = configuration_parsed or {}
65+
configuration_parsed.setdefault("endian", None)
6566
return cls(**configuration_parsed) # type: ignore[arg-type]
6667

6768
def to_dict(self) -> dict[str, JSON]:

src/zarr/codecs/sharding.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,8 @@ async def _decode_partial_single(
527527
chunk_spec.prototype,
528528
chunks_per_shard,
529529
all_chunk_coords,
530+
max_gap_bytes=shard_spec.config.sharding_coalesce_max_gap_bytes,
531+
max_coalesced_bytes=shard_spec.config.sharding_coalesce_max_bytes,
530532
)
531533

532534
if shard_dict_maybe is None:
@@ -846,10 +848,16 @@ async def _load_partial_shard_maybe(
846848
prototype: BufferPrototype,
847849
chunks_per_shard: tuple[int, ...],
848850
all_chunk_coords: set[tuple[int, ...]],
851+
max_gap_bytes: int,
852+
max_coalesced_bytes: int,
849853
) -> ShardMapping | None:
850854
"""
851855
Read chunks from `byte_getter` for the case where the read is less than a full shard.
852856
Returns a mapping of chunk coordinates to bytes or None.
857+
858+
`max_gap_bytes` and `max_coalesced_bytes` are forwarded to
859+
`Store.get_ranges` to control byte-range coalescing across the requested
860+
chunks.
853861
"""
854862
shard_index = await self._load_shard_index_maybe(byte_getter, chunks_per_shard)
855863
if shard_index is None:
@@ -873,7 +881,11 @@ async def _load_partial_shard_maybe(
873881
byte_ranges = [byte_range for _, byte_range in chunk_coord_byte_ranges]
874882
try:
875883
async for group in byte_getter.store.get_ranges(
876-
byte_getter.path, byte_ranges, prototype=prototype
884+
byte_getter.path,
885+
byte_ranges,
886+
prototype=prototype,
887+
max_gap_bytes=max_gap_bytes,
888+
max_coalesced_bytes=max_coalesced_bytes,
877889
):
878890
for idx, buf in group:
879891
if buf is not None:

src/zarr/core/array_spec.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
MemoryOrder,
88
parse_bool,
99
parse_fill_value,
10+
parse_int,
1011
parse_order,
1112
parse_shapelike,
1213
)
@@ -29,6 +30,8 @@ class ArrayConfigParams(TypedDict):
2930
order: NotRequired[MemoryOrder]
3031
write_empty_chunks: NotRequired[bool]
3132
read_missing_chunks: NotRequired[bool]
33+
sharding_coalesce_max_gap_bytes: NotRequired[int]
34+
sharding_coalesce_max_bytes: NotRequired[int]
3235

3336

3437
@dataclass(frozen=True)
@@ -45,22 +48,42 @@ class ArrayConfig:
4548
read_missing_chunks : bool
4649
If True, missing chunks will be filled with the array's fill value on read.
4750
If False, reading missing chunks will raise a ``ChunkNotFoundError``.
51+
sharding_coalesce_max_gap_bytes : int
52+
When reading multiple chunks from the same shard, nearby byte ranges
53+
separated by no more than this many bytes are coalesced into a single
54+
request to the store.
55+
sharding_coalesce_max_bytes : int
56+
Requests will not be coalesced if doing so would exceed this byte size.
4857
"""
4958

5059
order: MemoryOrder
5160
write_empty_chunks: bool
5261
read_missing_chunks: bool
62+
sharding_coalesce_max_gap_bytes: int
63+
sharding_coalesce_max_bytes: int
5364

5465
def __init__(
55-
self, order: MemoryOrder, write_empty_chunks: bool, *, read_missing_chunks: bool = True
66+
self,
67+
order: MemoryOrder,
68+
write_empty_chunks: bool,
69+
*,
70+
read_missing_chunks: bool = True,
71+
sharding_coalesce_max_gap_bytes: int = 1 << 20, # 1 MiB
72+
sharding_coalesce_max_bytes: int = 16 << 20, # 16 MiB
5673
) -> None:
5774
order_parsed = parse_order(order)
5875
write_empty_chunks_parsed = parse_bool(write_empty_chunks)
5976
read_missing_chunks_parsed = parse_bool(read_missing_chunks)
77+
sharding_coalesce_max_gap_bytes_parsed = parse_int(sharding_coalesce_max_gap_bytes)
78+
sharding_coalesce_max_bytes_parsed = parse_int(sharding_coalesce_max_bytes)
6079

6180
object.__setattr__(self, "order", order_parsed)
6281
object.__setattr__(self, "write_empty_chunks", write_empty_chunks_parsed)
6382
object.__setattr__(self, "read_missing_chunks", read_missing_chunks_parsed)
83+
object.__setattr__(
84+
self, "sharding_coalesce_max_gap_bytes", sharding_coalesce_max_gap_bytes_parsed
85+
)
86+
object.__setattr__(self, "sharding_coalesce_max_bytes", sharding_coalesce_max_bytes_parsed)
6487

6588
@classmethod
6689
def from_dict(cls, data: ArrayConfigParams) -> Self:
@@ -72,7 +95,8 @@ def from_dict(cls, data: ArrayConfigParams) -> Self:
7295
kwargs_out: ArrayConfigParams = {}
7396
for f in fields(ArrayConfig):
7497
field_name = cast(
75-
"Literal['order', 'write_empty_chunks', 'read_missing_chunks']", f.name
98+
"Literal['order', 'write_empty_chunks', 'read_missing_chunks', 'sharding_coalesce_max_gap_bytes', 'sharding_coalesce_max_bytes']",
99+
f.name,
76100
)
77101
if field_name not in data:
78102
kwargs_out[field_name] = zarr_config.get(f"array.{field_name}")
@@ -88,6 +112,8 @@ def to_dict(self) -> ArrayConfigParams:
88112
"order": self.order,
89113
"write_empty_chunks": self.write_empty_chunks,
90114
"read_missing_chunks": self.read_missing_chunks,
115+
"sharding_coalesce_max_gap_bytes": self.sharding_coalesce_max_gap_bytes,
116+
"sharding_coalesce_max_bytes": self.sharding_coalesce_max_bytes,
91117
}
92118

93119

src/zarr/core/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ def parse_bool(data: Any) -> bool:
217217
raise ValueError(f"Expected bool, got {data} instead.")
218218

219219

220+
def parse_int(data: Any) -> int:
221+
if isinstance(data, int) and not isinstance(data, bool):
222+
return data
223+
raise ValueError(f"Expected int, got {data} instead.")
224+
225+
220226
def _warn_write_empty_chunks_kwarg() -> None:
221227
# TODO: link to docs page on array configuration in this message
222228
msg = (

src/zarr/core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ def enable_gpu(self) -> ConfigSet:
9999
"read_missing_chunks": True,
100100
"target_shard_size_bytes": None,
101101
"rectilinear_chunks": False,
102+
"sharding_coalesce_max_gap_bytes": 1 << 20, # 1 MiB
103+
"sharding_coalesce_max_bytes": 16 << 20, # 16 MiB
102104
},
103105
"async": {"concurrency": 10, "timeout": None},
104106
"threading": {"max_workers": None},

tests/test_codecs/test_bytes.py

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,98 @@
55
import enum
66
import sys
77
import warnings
8-
from typing import Any, cast
8+
from typing import TYPE_CHECKING, Any, Literal, cast
99

10+
import numpy as np
1011
import pytest
1112

13+
import zarr
14+
from tests.conftest import Expect, ExpectFail
15+
from zarr.abc.codec import SupportsSyncCodec
1216
from zarr.codecs.bytes import (
1317
ENDIAN,
1418
BytesCodec,
1519
Endian,
1620
EndianLiteral,
1721
)
1822
from zarr.core.array_spec import ArrayConfig, ArraySpec
19-
from zarr.core.buffer import default_buffer_prototype
23+
from zarr.core.buffer import NDBuffer, default_buffer_prototype
24+
from zarr.core.dtype import get_data_type_from_native_dtype
2025
from zarr.core.dtype.npy.int import Int8, Int32
2126
from zarr.core.dtype.npy.structured import Struct
27+
from zarr.storage import StorePath
28+
29+
from .test_codecs import _AsyncArrayProxy
30+
31+
if TYPE_CHECKING:
32+
from zarr.abc.store import Store
33+
34+
35+
@pytest.mark.parametrize("store", ["local", "memory"], indirect=["store"])
36+
@pytest.mark.parametrize("input_dtype", [">u2", "<u2"])
37+
@pytest.mark.parametrize("store_endian", ["big", "little"])
38+
async def test_endian(
39+
store: Store,
40+
input_dtype: Literal[">u2", "<u2"],
41+
store_endian: Literal["big", "little"],
42+
) -> None:
43+
"""
44+
The `bytes` codec stores multi-byte data in the byte order configured on the
45+
codec, regardless of the input array's byte order, and reads it back to the
46+
original values. The input-dtype/store-endian cross-product exercises the
47+
encode-side byteswap (input byte order != store byte order) and the no-op
48+
case alike. Compression is disabled so the stored chunk is the codec's raw
49+
output and its byte layout can be asserted directly.
50+
"""
51+
data = np.arange(0, 256, dtype=input_dtype).reshape((16, 16))
52+
path = "endian"
53+
spath = StorePath(store, path)
54+
a = await zarr.api.asynchronous.create_array(
55+
spath,
56+
shape=data.shape,
57+
chunks=(16, 16),
58+
dtype="uint16",
59+
fill_value=0,
60+
compressors=None,
61+
serializer=BytesCodec(endian=store_endian),
62+
)
63+
64+
await _AsyncArrayProxy(a)[:, :].set(data)
65+
66+
# The stored chunk is laid out in the byte order configured on the codec.
67+
stored = await store.get(f"{path}/c/0/0", prototype=default_buffer_prototype())
68+
assert stored is not None
69+
expected_dtype = ">u2" if store_endian == "big" else "<u2"
70+
assert stored.to_bytes() == data.astype(expected_dtype).tobytes()
71+
72+
# ... and the data reads back to the original values.
73+
readback_data = await _AsyncArrayProxy(a)[:, :].get()
74+
assert np.array_equal(data, readback_data)
75+
76+
77+
def test_bytes_codec_supports_sync() -> None:
78+
assert isinstance(BytesCodec(), SupportsSyncCodec)
79+
80+
81+
def test_bytes_codec_sync_roundtrip() -> None:
82+
codec = BytesCodec()
83+
arr = np.arange(100, dtype="float64")
84+
zdtype = get_data_type_from_native_dtype(arr.dtype)
85+
spec = ArraySpec(
86+
shape=arr.shape,
87+
dtype=zdtype,
88+
fill_value=zdtype.cast_scalar(0),
89+
config=ArrayConfig(order="C", write_empty_chunks=True),
90+
prototype=default_buffer_prototype(),
91+
)
92+
nd_buf: NDBuffer = default_buffer_prototype().nd_buffer.from_numpy_array(arr)
93+
94+
codec = codec.evolve_from_array_spec(spec)
95+
96+
encoded = codec._encode_sync(nd_buf, spec)
97+
assert encoded is not None
98+
decoded = codec._decode_sync(encoded, spec)
99+
np.testing.assert_array_equal(arr, decoded.as_numpy_array())
22100

23101

24102
@pytest.mark.parametrize("endian", ENDIAN)
@@ -46,6 +124,43 @@ def test_bytes_codec_json_roundtrip(endian: EndianLiteral) -> None:
46124
assert restored == codec
47125

48126

127+
# to_dict and from_dict are inverses over this (endian setting, wire dict) mapping:
128+
# to_dict turns the endian setting into the dict; from_dict recovers it.
129+
_ENDIAN_DICT_CASES: list[Expect[EndianLiteral | None, dict[str, Any]]] = [
130+
Expect(
131+
input="little",
132+
output={"name": "bytes", "configuration": {"endian": "little"}},
133+
id="little",
134+
),
135+
Expect(
136+
input="big",
137+
output={"name": "bytes", "configuration": {"endian": "big"}},
138+
id="big",
139+
),
140+
Expect(input=None, output={"name": "bytes"}, id="missing"),
141+
]
142+
143+
144+
@pytest.mark.parametrize("case", _ENDIAN_DICT_CASES, ids=lambda c: c.id)
145+
def test_to_dict(case: Expect[EndianLiteral | None, dict[str, Any]]) -> None:
146+
assert BytesCodec(endian=case.input).to_dict() == case.output
147+
148+
149+
@pytest.mark.parametrize("case", _ENDIAN_DICT_CASES, ids=lambda c: c.id)
150+
def test_from_dict(case: Expect[EndianLiteral | None, dict[str, Any]]) -> None:
151+
assert BytesCodec.from_dict(case.output).endian == case.input
152+
153+
154+
@pytest.mark.parametrize("endian", ["little", "big", pytest.param(None, id="missing")])
155+
def test_roundtrip(endian: EndianLiteral | None) -> None:
156+
codec = BytesCodec(endian=endian)
157+
158+
encoded = codec.to_dict()
159+
roundtripped = BytesCodec.from_dict(encoded)
160+
161+
assert codec == roundtripped
162+
163+
49164
@pytest.mark.parametrize(
50165
("member", "expected"),
51166
[("little", "little"), ("big", "big")],
@@ -105,14 +220,25 @@ def test_bytes_codec_init_with_deprecated_class_member() -> None:
105220
assert codec.endian == "little"
106221

107222

108-
def test_bytes_codec_rejects_unknown_endian() -> None:
223+
@pytest.mark.parametrize(
224+
"case",
225+
[
226+
ExpectFail(
227+
input="north",
228+
exception=ValueError,
229+
id="unknown-string",
230+
msg="endian must be one of",
231+
),
232+
],
233+
ids=lambda c: c.id,
234+
)
235+
def test_bytes_codec_rejects_unknown_endian(case: ExpectFail[Any]) -> None:
109236
"""
110-
`BytesCodec.__init__` raises `ValueError` when given a string outside
237+
`BytesCodec.__init__` raises `ValueError` when given a value outside
111238
`ENDIAN`, and the error message names the offending parameter.
112239
"""
113-
kwargs: dict[str, Any] = {"endian": "north"}
114-
with pytest.raises(ValueError, match="endian must be one of"):
115-
BytesCodec(**kwargs)
240+
with case.raises():
241+
BytesCodec(endian=case.input)
116242

117243

118244
def test_endian_attribute_error_for_unknown_member() -> None:

0 commit comments

Comments
 (0)