Skip to content

Commit 093a153

Browse files
ilan-goldd-v-b
andauthored
feat: subchunk write order (#3826)
* feat: subchunk write order * chore: export `SubchunkWriteOrder` * chore: docs * chore: relnote * rename * refactor: no enums * Update docs/user-guide/performance.md Co-authored-by: Davis Bennett <davis.v.bennett@gmail.com> * feat: deterministic but random order * fix: make vectorized fetching less reliant on matching order * chore: add hypothesis * refactor: dead code * refactor: more cleanup * don't shard unless there is something to shard * fix: dont mix chunk grid and sharding --------- Co-authored-by: Davis Bennett <davis.v.bennett@gmail.com>
1 parent 5ca1690 commit 093a153

6 files changed

Lines changed: 235 additions & 39 deletions

File tree

changes/3826.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a `subchunk_write_order` option to `ShardingCodec` to allow for `morton`, `unordered`, `lexicographic`, and `colexicographic` subchunk orderings.

docs/user-guide/performance.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ bytes within chunks of an array may improve the compression ratio, depending on
113113
the structure of the data, the compression algorithm used, and which compression
114114
filters (e.g., byte-shuffle) have been applied.
115115

116+
### Subchunk memory layout
117+
118+
The order of chunks **within each shard** can be changed via the `subchunk_write_order` parameter of the `ShardingCodec`. That parameter is a string which must be one of `["morton", "lexicographic", "colexicographic", "unordered"]`.
119+
120+
By default [`morton`](https://en.wikipedia.org/wiki/Z-order_curve) order provides good spatial locality however [`lexicographic` (i.e., row-major)](https://en.wikipedia.org/wiki/Row-_and_column-major_order), for example, may be better suited to "batched" workflows where some form of sequential reading through a fixed number of outer dimensions is desired. The options are `lexicographic`, `morton`, `unordered` (i.e., random), and `colexicographic`.
121+
122+
116123
### Empty chunks
117124

118125
It is possible to configure how Zarr handles the storage of chunks that are "empty"

src/zarr/codecs/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
Zstd,
3030
)
3131
from zarr.codecs.scale_offset import ScaleOffset
32-
from zarr.codecs.sharding import ShardingCodec, ShardingCodecIndexLocation
32+
from zarr.codecs.sharding import ShardingCodec, ShardingCodecIndexLocation, SubchunkWriteOrder
3333
from zarr.codecs.transpose import TransposeCodec
3434
from zarr.codecs.vlen_utf8 import VLenBytesCodec, VLenUTF8Codec
3535
from zarr.codecs.zstd import ZstdCodec
@@ -47,6 +47,7 @@
4747
"ScaleOffset",
4848
"ShardingCodec",
4949
"ShardingCodecIndexLocation",
50+
"SubchunkWriteOrder",
5051
"TransposeCodec",
5152
"VLenBytesCodec",
5253
"VLenUTF8Codec",

src/zarr/codecs/sharding.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass, replace
55
from enum import Enum
66
from functools import lru_cache
7-
from typing import TYPE_CHECKING, Any, NamedTuple, cast
7+
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
88

99
import numpy as np
1010
import numpy.typing as npt
@@ -46,8 +46,6 @@
4646
BasicIndexer,
4747
ChunkProjection,
4848
SelectorTuple,
49-
_morton_order,
50-
_morton_order_keys,
5149
c_order_iter,
5250
get_indexer,
5351
morton_order_iter,
@@ -64,7 +62,7 @@
6462

6563
if TYPE_CHECKING:
6664
from collections.abc import Iterator
67-
from typing import Self
65+
from typing import Final, Self
6866

6967
from zarr.core.common import JSON
7068
from zarr.core.dtype.wrapper import TBaseDType, TBaseScalar, ZDType
@@ -83,6 +81,15 @@ class ShardingCodecIndexLocation(Enum):
8381
end = "end"
8482

8583

84+
SubchunkWriteOrder = Literal["morton", "unordered", "lexicographic", "colexicographic"]
85+
SUBCHUNK_WRITE_ORDER: Final[tuple[str, str, str, str]] = (
86+
"morton",
87+
"unordered",
88+
"lexicographic",
89+
"colexicographic",
90+
)
91+
92+
8693
def parse_index_location(data: object) -> ShardingCodecIndexLocation:
8794
return parse_enum(data, ShardingCodecIndexLocation)
8895

@@ -272,14 +279,13 @@ def to_dict_vectorized(
272279
dict mapping chunk coordinate tuples to Buffer or None
273280
"""
274281
starts, ends, valid = self.index.get_chunk_slices_vectorized(chunk_coords_array)
275-
chunk_coords_keys = _morton_order_keys(self.index.chunks_per_shard)
276282

277283
result: dict[tuple[int, ...], Buffer | None] = {}
278-
for i, coords in enumerate(chunk_coords_keys):
284+
for i, coords in enumerate(chunk_coords_array):
279285
if valid[i]:
280-
result[coords] = self.buf[int(starts[i]) : int(ends[i])]
286+
result[tuple(coords.ravel())] = self.buf[int(starts[i]) : int(ends[i])]
281287
else:
282-
result[coords] = None
288+
result[tuple(coords.ravel())] = None
283289

284290
return result
285291

@@ -293,7 +299,9 @@ class ShardingCodec(
293299
chunk_shape: tuple[int, ...]
294300
codecs: tuple[Codec, ...]
295301
index_codecs: tuple[Codec, ...]
302+
rng: np.random.Generator | None
296303
index_location: ShardingCodecIndexLocation = ShardingCodecIndexLocation.end
304+
subchunk_write_order: SubchunkWriteOrder = "morton"
297305

298306
def __init__(
299307
self,
@@ -302,16 +310,24 @@ def __init__(
302310
codecs: Iterable[Codec | dict[str, JSON]] = (BytesCodec(),),
303311
index_codecs: Iterable[Codec | dict[str, JSON]] = (BytesCodec(), Crc32cCodec()),
304312
index_location: ShardingCodecIndexLocation | str = ShardingCodecIndexLocation.end,
313+
subchunk_write_order: SubchunkWriteOrder = "morton",
314+
rng: np.random.Generator | None = None,
305315
) -> None:
306316
chunk_shape_parsed = parse_shapelike(chunk_shape)
307317
codecs_parsed = parse_codecs(codecs)
308318
index_codecs_parsed = parse_codecs(index_codecs)
309319
index_location_parsed = parse_index_location(index_location)
320+
if subchunk_write_order not in SUBCHUNK_WRITE_ORDER:
321+
raise ValueError(
322+
f"Unrecognized subchunk write order: {subchunk_write_order}. Only {SUBCHUNK_WRITE_ORDER} are allowed."
323+
)
310324

311325
object.__setattr__(self, "chunk_shape", chunk_shape_parsed)
312326
object.__setattr__(self, "codecs", codecs_parsed)
313327
object.__setattr__(self, "index_codecs", index_codecs_parsed)
314328
object.__setattr__(self, "index_location", index_location_parsed)
329+
object.__setattr__(self, "subchunk_write_order", subchunk_write_order)
330+
object.__setattr__(self, "rng", rng)
315331

316332
# Use instance-local lru_cache to avoid memory leaks
317333

@@ -324,14 +340,15 @@ def __init__(
324340

325341
# todo: typedict return type
326342
def __getstate__(self) -> dict[str, Any]:
327-
return self.to_dict()
343+
return {"rng": self.rng, **self.to_dict()}
328344

329345
def __setstate__(self, state: dict[str, Any]) -> None:
330346
config = state["configuration"]
331347
object.__setattr__(self, "chunk_shape", parse_shapelike(config["chunk_shape"]))
332348
object.__setattr__(self, "codecs", parse_codecs(config["codecs"]))
333349
object.__setattr__(self, "index_codecs", parse_codecs(config["index_codecs"]))
334350
object.__setattr__(self, "index_location", parse_index_location(config["index_location"]))
351+
object.__setattr__(self, "rng", state["rng"])
335352

336353
# Use instance-local lru_cache to avoid memory leaks
337354
# object.__setattr__(self, "_get_chunk_spec", lru_cache()(self._get_chunk_spec))
@@ -509,6 +526,24 @@ async def _decode_partial_single(
509526
else:
510527
return out
511528

529+
def _subchunk_order_iter(
530+
self, chunks_per_shard: tuple[int, ...], subchunk_write_order: SubchunkWriteOrder
531+
) -> Iterable[tuple[int, ...]]:
532+
match subchunk_write_order:
533+
case "morton":
534+
subchunk_iter = morton_order_iter(chunks_per_shard)
535+
case "lexicographic":
536+
subchunk_iter = np.ndindex(chunks_per_shard)
537+
case "colexicographic":
538+
subchunk_iter = (c[::-1] for c in np.ndindex(chunks_per_shard[::-1]))
539+
case "unordered":
540+
subchunk_list = list(np.ndindex(chunks_per_shard))
541+
(self.rng if self.rng is not None else np.random.default_rng()).shuffle(
542+
subchunk_list
543+
)
544+
subchunk_iter = iter(subchunk_list)
545+
return subchunk_iter
546+
512547
async def _encode_single(
513548
self,
514549
shard_array: NDBuffer,
@@ -526,8 +561,7 @@ async def _encode_single(
526561
chunk_grid=ChunkGrid.from_sizes(shard_shape, chunk_shape),
527562
)
528563
)
529-
530-
shard_builder = dict.fromkeys(morton_order_iter(chunks_per_shard))
564+
shard_builder = dict.fromkeys(self._subchunk_order_iter(chunks_per_shard, "lexicographic"))
531565

532566
await self.codec_pipeline.write(
533567
[
@@ -570,7 +604,7 @@ async def _encode_partial_single(
570604
)
571605

572606
if self._is_complete_shard_write(indexer, chunks_per_shard):
573-
shard_dict = dict.fromkeys(morton_order_iter(chunks_per_shard))
607+
shard_dict = dict.fromkeys(self._subchunk_order_iter(chunks_per_shard, "lexicographic"))
574608
else:
575609
shard_reader = await self._load_full_shard_maybe(
576610
byte_getter=byte_setter,
@@ -580,7 +614,7 @@ async def _encode_partial_single(
580614
shard_reader = shard_reader or _ShardReader.create_empty(chunks_per_shard)
581615
# Use vectorized lookup for better performance
582616
shard_dict = shard_reader.to_dict_vectorized(
583-
np.asarray(_morton_order(chunks_per_shard))
617+
np.array(list(self._subchunk_order_iter(chunks_per_shard, "lexicographic")))
584618
)
585619

586620
await self.codec_pipeline.write(
@@ -619,7 +653,7 @@ async def _encode_shard_dict(
619653

620654
template = buffer_prototype.buffer.create_zero_length()
621655
chunk_start = 0
622-
for chunk_coords in morton_order_iter(chunks_per_shard):
656+
for chunk_coords in self._subchunk_order_iter(chunks_per_shard, self.subchunk_write_order):
623657
value = map.get(chunk_coords)
624658
if value is None:
625659
continue

src/zarr/testing/strategies.py

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
import zarr
1414
from zarr.abc.store import RangeByteRequest, Store
1515
from zarr.codecs.bytes import BytesCodec
16-
from zarr.core.array import Array
16+
from zarr.codecs.crc32c_ import Crc32cCodec
17+
from zarr.codecs.sharding import SUBCHUNK_WRITE_ORDER, ShardingCodec, SubchunkWriteOrder
18+
from zarr.codecs.zstd import ZstdCodec
19+
from zarr.core.array import Array, CompressorsLike, SerializerLike
1720
from zarr.core.chunk_key_encodings import DefaultChunkKeyEncoding
1821
from zarr.core.common import JSON, AccessModeLiteral, ZarrFormat
1922
from zarr.core.dtype import get_data_type_from_native_dtype
@@ -127,6 +130,9 @@ def dimension_names(draw: st.DrawFn, *, ndim: int | None = None) -> list[None |
127130
return draw(st.none() | st.lists(st.none() | simple_text, min_size=ndim, max_size=ndim)) # type: ignore[arg-type]
128131

129132

133+
subchunk_write_orders: st.SearchStrategy[SubchunkWriteOrder] = st.sampled_from(SUBCHUNK_WRITE_ORDER)
134+
135+
130136
@st.composite
131137
def array_metadata(
132138
draw: st.DrawFn,
@@ -255,6 +261,7 @@ def arrays(
255261
arrays: st.SearchStrategy | None = None,
256262
attrs: st.SearchStrategy = attrs,
257263
zarr_formats: st.SearchStrategy = zarr_formats,
264+
subchunk_write_orders: SearchStrategy[SubchunkWriteOrder] = subchunk_write_orders,
258265
open_mode: AccessModeLiteral = "w",
259266
) -> AnyArray:
260267
store = draw(stores, label="store")
@@ -266,20 +273,11 @@ def arrays(
266273
arrays = numpy_arrays(shapes=shapes)
267274
nparray = draw(arrays, label="array data")
268275
dim_names: None | list[str | None] = None
276+
serializer: SerializerLike = "auto"
277+
compressors_unsearched: CompressorsLike = "auto"
269278

270279
# For v3 arrays, optionally use RectilinearChunkGridMetadata
271280
chunk_grid_meta: RegularChunkGridMetadata | RectilinearChunkGridMetadata | None = None
272-
shard_shape = None
273-
if zarr_format == 3:
274-
chunk_grid_meta = draw(chunk_grids(shape=nparray.shape), label="chunk grid")
275-
276-
# Sharding is only supported with regular chunk grids, and has complex
277-
# divisibility constraints that don't play well with hypothesis shrinking.
278-
# Disabled for now — sharding should be tested separately.
279-
280-
dim_names = draw(dimension_names(ndim=nparray.ndim), label="dimension names")
281-
else:
282-
dim_names = None
283281

284282
# test that None works too.
285283
fill_value = draw(st.one_of([st.none(), npst.from_dtype(nparray.dtype)]))
@@ -295,27 +293,48 @@ def arrays(
295293
# - RectilinearChunkGridMetadata -> nested list of ints (triggers rectilinear path)
296294
# - v2 -> flat tuple of ints
297295
chunks_param: tuple[int, ...] | list[list[int]]
298-
if zarr_format == 3 and chunk_grid_meta is not None:
296+
shard_shape = None
297+
dim_names = None
298+
if zarr_format == 3:
299+
chunk_grid_meta = draw(st.none() | chunk_grids(shape=nparray.shape), label="chunk grid")
300+
dim_names = draw(dimension_names(ndim=nparray.ndim), label="dimension names")
299301
if isinstance(chunk_grid_meta, RectilinearChunkGridMetadata):
300302
chunks_param = [
301303
list(dim) if isinstance(dim, tuple) else [dim]
302304
for dim in chunk_grid_meta.chunk_shapes
303305
]
304-
else:
306+
elif isinstance(chunk_grid_meta, RegularChunkGridMetadata):
305307
chunks_param = chunk_grid_meta.chunk_shape
308+
else:
309+
chunks_param = draw(chunk_shapes(shape=nparray.shape), label="chunk shape")
310+
311+
if all(s > c and c > 1 for s, c in zip(nparray.shape, chunks_param, strict=True)):
312+
shard_shape = draw(
313+
st.none() | shard_shapes(shape=nparray.shape, chunk_shape=chunks_param),
314+
label="shard shape",
315+
)
316+
if shard_shape is not None:
317+
subchunk_write_order = draw(subchunk_write_orders)
318+
serializer = ShardingCodec(
319+
subchunk_write_order=subchunk_write_order,
320+
codecs=[BytesCodec(), ZstdCodec()],
321+
index_codecs=[BytesCodec(), Crc32cCodec()],
322+
chunk_shape=chunks_param,
323+
)
324+
compressors_unsearched = None
306325
else:
307326
chunks_param = draw(chunk_shapes(shape=nparray.shape), label="chunk shape")
308-
309327
a = root.create_array(
310328
array_path,
311329
shape=nparray.shape,
312330
chunks=chunks_param,
313331
shards=shard_shape,
314332
dtype=nparray.dtype,
315333
attributes=attributes,
316-
# compressor=compressor, # FIXME
334+
compressors=compressors_unsearched, # FIXME
317335
fill_value=fill_value,
318336
dimension_names=dim_names,
337+
serializer=serializer,
319338
)
320339

321340
assert isinstance(a, Array)
@@ -329,12 +348,15 @@ def arrays(
329348

330349
# Verify chunks — for rectilinear grids, .chunks raises
331350
if zarr_format == 3:
332-
if isinstance(a.metadata.chunk_grid, RectilinearChunkGridMetadata):
333-
assert shard_shape is None
334-
else:
335-
assert isinstance(a.metadata.chunk_grid, RegularChunkGridMetadata)
336-
assert a.metadata.chunk_grid.chunk_shape == a.chunks
351+
assert shard_shape == a.shards
352+
if isinstance(a.metadata.chunk_grid, RegularChunkGridMetadata):
353+
assert a.metadata.chunk_grid.chunk_shape == (
354+
a.shards if shard_shape is not None else a.chunks
355+
)
337356
assert shard_shape == a.shards
357+
else:
358+
assert isinstance(a.metadata.chunk_grid, RectilinearChunkGridMetadata)
359+
assert shard_shape is None
338360

339361
assert a.basename == name, (a.basename, name)
340362
assert dict(a.attrs) == expected_attrs

0 commit comments

Comments
 (0)