Files
2026-07-13 13:17:40 +08:00

686 lines
27 KiB
Python

from __future__ import annotations
import logging
import math
import numbers
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional
import numpy as np
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.util import (
_check_import,
_is_local_scheme,
iterate_with_retry,
)
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from fsspec.spec import AbstractFileSystem
from pyarrow import fs as pyarrow_fs
from zarr import Array as ZarrArray
from zarr.hierarchy import Group as ZarrGroup
from ray.data.context import DataContext
ZarrRoot = ZarrGroup | ZarrArray
@dataclass(frozen=True)
class ZarrArrayMeta:
"""``shape``/``chunks``/``dtype`` for a single Zarr v2 array."""
shape: tuple[int, ...]
chunks: tuple[int, ...]
dtype: str
@classmethod
def from_zarr_array(cls, arr: "ZarrArray") -> ZarrArrayMeta:
return cls(
shape=tuple(int(s) for s in arr.shape),
chunks=tuple(int(c) for c in arr.chunks),
dtype=str(arr.dtype),
)
@property
def rank(self) -> int:
return len(self.shape)
@property
def itemsize(self) -> int:
return np.dtype(self.dtype).itemsize
def effective_chunks(
self,
array_name: str,
user_chunk_shape: tuple[int, ...] | dict[str, tuple[int, ...]] | None,
) -> tuple[int, ...]:
"""Resolve the user's ``chunk_shapes`` override(s) against this array's chunks.
A single sequence overrides the leading axes (trailing axes keep the
native chunks), so one ``chunk_shapes=[16]`` applies across arrays of
different ranks. A dict maps array path → that array's override prefix;
arrays absent from it keep native chunks. ``None`` keeps native chunks;
an override longer than the array's rank raises ``ValueError``.
"""
if user_chunk_shape is None:
return self.chunks
if isinstance(user_chunk_shape, dict):
user_chunk_shape = user_chunk_shape.get(array_name)
if user_chunk_shape is None:
return self.chunks
if len(user_chunk_shape) > self.rank:
raise ValueError(
f"chunk_shapes override for array {array_name!r} has "
f"{len(user_chunk_shape)} axes but array of shape "
f"{self.shape!r} has rank {self.rank}. Each chunk_shapes "
f"override may not be longer than its target array's rank."
)
return user_chunk_shape + self.chunks[len(user_chunk_shape) :]
def grid_shape(self, chunks: tuple[int, ...]) -> tuple[int, ...]:
"""Number of chunks along each axis under the given chunk shape."""
return tuple(math.ceil(s / c) for s, c in zip(self.shape, chunks))
def chunk_slices(
self, chunk_index: tuple[int, ...], chunks: tuple[int, ...]
) -> tuple[tuple[int, int], ...]:
"""Per-axis ``(start, stop)`` for ``array[chunk_index]`` under ``chunks``.
Trailing-edge chunks are clamped to ``shape[i]``, so they may be
shorter than ``chunks[i]``. No padding is applied.
"""
return tuple(
(i * c, min((i + 1) * c, s))
for i, c, s in zip(chunk_index, chunks, self.shape)
)
# ---------------------------------------------------------------------------
# Chunk reading
# ---------------------------------------------------------------------------
def _read_chunk(
root: ZarrRoot,
array_name: str,
chunk_slices: tuple[tuple[int, int], ...],
retry_match: Optional[List[str]] = None,
) -> np.ndarray:
"""Read ``array[chunk_slices]`` as an ndarray.
The underlying filesystem's own retry policy still applies underneath.
"""
def _read() -> np.ndarray:
indexer = tuple(slice(s, e) for s, e in chunk_slices)
arr = root if array_name == "" else root[array_name]
return np.asarray(arr[indexer])
if not retry_match:
return _read()
# TODO(Artur): This would be more elegant with a general retry helper for non-iterables.
return next(
iterate_with_retry(
lambda: [_read()], description="read a Zarr chunk", match=retry_match
)
)
@dataclass(frozen=True)
class _ChunkRange:
"""A contiguous slice ``[flat_start, flat_stop)`` of an array's chunk grid.
The flat indices address the row-major flattening of the chunk grid; the
read fn unravels each to an N-D ``chunk_index`` lazily on the worker. Keeping
a range (not a materialized per-chunk list) makes read-task planning
O(parallelism) rather than O(total chunks) -- important for stores with very
many chunks.
"""
array_name: str
meta: ZarrArrayMeta
chunks: tuple[int, ...]
grid: tuple[int, ...]
flat_start: int
flat_stop: int
@dataclass(frozen=True)
class _AlignedChunkDescriptor:
"""One wide row: a global axis-0 range ``[t_start, t_stop)`` across the
aligned arrays. With ``overlap > 0`` the row's data extends to
``t_stop_data = min(t_stop + overlap, shape[0])`` (lookahead so windows
starting in this row reach their tail without crossing a row boundary).
"""
chunk_index: int
t_start: int
t_stop: int
t_stop_data: int
def _create_read_fn(
chunk_range: _ChunkRange,
root: ZarrRoot,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> Callable[[], Iterable[Block]]:
"""Build a callable that materializes one block for a chunk-grid range.
This is the case where arrays are not aligned. Chunks are enumerated lazily
(on the worker) from ``chunk_range``. ``per_task_row_limit`` caps how many
chunks this task reads so a downstream ``limit`` reads only what it needs
(``None`` reads the whole range).
"""
cr = chunk_range
stop = cr.flat_stop
if per_task_row_limit is not None:
stop = min(stop, cr.flat_start + per_task_row_limit)
def read_fn() -> Iterable[Block]:
builder = DelegatingBlockBuilder()
for flat_index in range(cr.flat_start, stop):
chunk_index = tuple(int(i) for i in np.unravel_index(flat_index, cr.grid))
chunk_slices = cr.meta.chunk_slices(chunk_index, cr.chunks)
builder.add(
{
"array": cr.array_name,
"chunk_index": chunk_index,
"chunk_slices": chunk_slices,
"chunk": _read_chunk(
root, cr.array_name, chunk_slices, retry_match
),
}
)
yield builder.build()
return read_fn
def _create_aligned_read_fn(
batch: list[_AlignedChunkDescriptor],
aligned_array_names: list[str],
root: ZarrRoot,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> Callable[[], Iterable[Block]]:
"""Build a callable for aligned (wide-row) reads.
Each output row carries ``t_start``, ``t_stop``, and one column per
aligned array holding that array's ``[t_start:t_stop, ...]`` slice at
its natural shape (edge rows may be shorter). All arrays in one row
share the same axis-0 range.
This is the case where arrays are aligned on axis 0. ``per_task_row_limit``
caps how many rows this task reads (``None`` reads the whole batch).
"""
batch = batch[:per_task_row_limit]
def read_fn() -> Iterable[Block]:
builder = DelegatingBlockBuilder()
for d in batch:
row: dict[str, Any] = {"t_start": d.t_start, "t_stop": d.t_stop}
for name in aligned_array_names:
row[name] = _read_chunk(
root, name, ((d.t_start, d.t_stop_data),), retry_match
)
builder.add(row)
yield builder.build()
return read_fn
def _is_positive_int(x) -> bool:
"""True for a positive integer, including NumPy integers; False for bool."""
return not isinstance(x, bool) and isinstance(x, numbers.Integral) and int(x) > 0
def _validate_chunk_shapes_dict(chunk_shapes: dict) -> dict[str, tuple[int, ...]]:
"""Normalize chunk_shapes keys to store paths and validate their values."""
from zarr.util import normalize_storage_path
normalized: dict[str, tuple[int, ...]] = {}
for k, v in chunk_shapes.items():
if (
not isinstance(v, (tuple, list))
or not v
or not all(_is_positive_int(x) for x in v)
):
raise ValueError(
f"chunk_shapes[{k!r}] must be a non-empty sequence of positive "
f"integers (list or tuple), got {v!r}"
)
normalized[normalize_storage_path(k)] = tuple(int(x) for x in v)
return normalized
# ---------------------------------------------------------------------------
# Datasource
# ---------------------------------------------------------------------------
class ZarrV2Datasource(Datasource):
"""Reads one or more Zarr v2 arrays into a Ray Data ``Dataset``.
Emits long-form rows (one per chunk per array) or, with
``align_axis_0=True``, wide rows (one per axis-0 chunk, one column per
array). See :func:`ray.data.read_zarr` for the row schemas and full API.
"""
def __init__(
self,
path: str,
filesystem: pyarrow_fs.FileSystem | AbstractFileSystem | None = None,
chunk_shapes: dict[str, list] | list | None = None,
array_paths: list[str] | None = None,
allow_full_metadata_scan: bool = False,
align_axis_0: bool = False,
overlap: int = 0,
) -> None:
super().__init__()
_check_import(self, module="zarr", package="zarr")
import zarr
_check_import(self, module="fsspec", package="fsspec")
from fsspec.spec import AbstractFileSystem
if int(zarr.__version__.split(".")[0]) >= 3:
raise ImportError(
f"read_zarr supports zarr-python 2.x (Zarr v2 stores), but found "
f"zarr=={zarr.__version__}. Install a compatible version with "
f"`pip install 'zarr<3'`."
)
self.allow_full_metadata_scan = allow_full_metadata_scan
self.paths = [str(path)]
# ``local://`` stores live on the driver's local disk, so pin reads to
# the driver node (workers on other nodes can't see those files).
self._supports_distributed_reads = not _is_local_scheme(self.paths)
# Resolve filesystem + store path. The order of precedence:
# 1. Explicit ``filesystem=`` always wins.
# 2. ``.zip`` URL/path: auto-wrap with fsspec's ZipFileSystem.
# 3. Otherwise delegate to Ray Data's standard URL to filesystem
# helper (the same one every other ``read_*`` API uses).
# "store path" is the path to the Zarr store, relative to the filesystem root.
# It is used to construct the Zarr root object.
if filesystem is None and self.paths[0].endswith(".zip"):
import fsspec
self._fs = fsspec.filesystem("zip", fo=self.paths[0])
self._store_path = ""
elif filesystem is None:
from fsspec.implementations.arrow import ArrowFSWrapper
from ray.data.datasource.path_util import (
_resolve_paths_and_filesystem,
)
resolved_paths, pa_fs = _resolve_paths_and_filesystem([self.paths[0]])
self._fs = ArrowFSWrapper(pa_fs)
self._store_path = resolved_paths[0].rstrip("/")
else:
from pyarrow.fs import FileSystem
if isinstance(filesystem, AbstractFileSystem):
self._fs = filesystem
elif isinstance(filesystem, FileSystem):
from fsspec.implementations.arrow import ArrowFSWrapper
self._fs = ArrowFSWrapper(filesystem)
else:
raise TypeError(
f"filesystem must be pyarrow.fs.FileSystem or "
f"fsspec.spec.AbstractFileSystem, got "
f"{type(filesystem).__name__}"
)
from fsspec.implementations.zip import ZipFileSystem
if isinstance(self._fs, ZipFileSystem) and self.paths[0].endswith(".zip"):
# An explicit archive filesystem: the store is the archive root,
# not a ``.zip``-named entry inside it.
self._store_path = ""
else:
from fsspec.core import split_protocol
_, store_path = split_protocol(self.paths[0])
self._store_path = store_path.rstrip("/")
if chunk_shapes is not None and not isinstance(
chunk_shapes, (tuple, list, dict)
):
raise ValueError(
f"chunk_shapes must be a non-empty sequence of positive "
f"integers (list or tuple), or a dict, got {chunk_shapes!r}"
)
self.chunk_shapes: tuple[int, ...] | dict[str, tuple[int, ...]] | None = None
if chunk_shapes is not None:
if isinstance(chunk_shapes, dict):
self.chunk_shapes = _validate_chunk_shapes_dict(chunk_shapes)
else:
if not chunk_shapes or not all(
_is_positive_int(x) for x in chunk_shapes
):
raise ValueError(
"chunk_shapes must be a non-empty sequence of positive integers "
f"(list or tuple), got {chunk_shapes!r}"
)
self.chunk_shapes = tuple(int(x) for x in chunk_shapes)
# Open the store with zarr (consolidated metadata when available).
# Detect consolidation by *trying* ``open_consolidated``.
store = self._fs.get_mapper(self._store_path)
try:
self.root = zarr.open_consolidated(store, mode="r")
self._consolidated = True
except KeyError:
self.root = zarr.open(store, mode="r")
self._consolidated = False
self._metadata_by_path = self._load_metadata(array_paths)
if not self._metadata_by_path:
raise ValueError(
f"No arrays discovered in Zarr store at {self.paths[0]!r}."
)
# Reject per-array overrides that do not correspond to any selected
# array in this read.
if isinstance(self.chunk_shapes, dict):
unknown_chunk_shape_keys = sorted(
set(self.chunk_shapes) - set(self._metadata_by_path)
)
if unknown_chunk_shape_keys:
raise ValueError(
f"Unknown array path(s) in chunk_shapes: {unknown_chunk_shape_keys}"
)
if not align_axis_0:
self._aligned_array_names = None
else:
scalar_arrays = sorted(
name for name, meta in self._metadata_by_path.items() if not meta.shape
)
if scalar_arrays:
raise ValueError(
f"align_axis_0=True requires every selected array to have "
f"at least one axis, but these are 0-D (scalar): "
f"{scalar_arrays}. Drop them with array_paths=[...]."
)
shape0_by_array = {
name: meta.shape[0] for name, meta in self._metadata_by_path.items()
}
if len(set(shape0_by_array.values())) > 1:
raise ValueError(
f"All selected arrays must share shape[0] when "
f"align_axis_0=True. Got: {shape0_by_array}. Pass a "
f"shape-compatible subset via array_paths=[...]."
)
self._aligned_array_names = list(self._metadata_by_path.keys())
# Validate overlap. Only meaningful when arrays are co-iterated as
# wide rows, since the trailing lookahead is exposed via the
# per-array column being longer than ``t_stop - t_start``.
if not isinstance(overlap, int) or overlap < 0:
raise ValueError(f"overlap must be a non-negative integer, got {overlap!r}")
if overlap and self._aligned_array_names is None:
raise ValueError(
"overlap requires align_axis_0=True. In the default long-form "
"(chunk-per-row) mode, there's no wide row to extend forward — "
"the ``chunk_slices`` column on each chunk row already exposes "
"the global axis-0 range."
)
self.overlap = overlap
# Resolve per-array chunk geometry. ``effective_chunks`` raises a
# ``ValueError`` if a shared ``chunk_shapes`` prefix or any per-array
# ``chunk_shapes`` override is longer than the target array's rank —
# so this loop is also where rank validation happens.
self._array_chunks: dict[str, tuple[int, ...]] = {}
self._array_grids: dict[str, tuple[int, ...]] = {}
for name, meta in self._metadata_by_path.items():
chunks = meta.effective_chunks(name, self.chunk_shapes)
self._array_chunks[name] = chunks
self._array_grids[name] = meta.grid_shape(chunks)
# If aligned, all listed arrays must share the same axis-0 chunk size
# so each wide row corresponds to one axis-0 step across every array.
if self._aligned_array_names is not None:
axis_0_chunks = {
name: self._array_chunks[name][0] for name in self._aligned_array_names
}
unique = set(axis_0_chunks.values())
if len(unique) > 1:
raise ValueError(
f"Aligned arrays must share the same axis-0 chunk size. "
f"Got: {axis_0_chunks}. Pass chunk_shapes=[N] (or a "
f"per-array chunk_shapes dict that resolves all aligned "
f"arrays to the same axis-0 prefix) to re-tile them."
)
@property
def supports_distributed_reads(self) -> bool:
return self._supports_distributed_reads
def estimate_inmemory_data_size(self) -> Optional[int]:
"""Total bytes = sum over selected arrays of ``prod(shape) * itemsize``."""
return sum(
math.prod(meta.shape) * meta.itemsize
for meta in self._metadata_by_path.values()
)
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
"""Enumerate every chunk and wrap it (or batches of chunks) in ReadTasks."""
from ray.data.context import DataContext
retry_match = (data_context or DataContext.get_current()).retried_io_errors
if self._aligned_array_names is not None:
return self._get_aligned_read_tasks(
parallelism, per_task_row_limit, retry_match
)
return self._get_long_form_read_tasks(
parallelism, per_task_row_limit, retry_match
)
def _get_long_form_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> List[ReadTask]:
read_tasks: List[ReadTask] = []
for name, meta in self._metadata_by_path.items():
chunks = self._array_chunks[name]
grid = self._array_grids[name]
n_chunks = math.prod(grid)
if n_chunks == 0:
continue
# Split the chunk grid into contiguous flat-index ranges. This is
# O(n_tasks), not O(n_chunks): we never materialize a per-chunk list
# on the driver -- the read fn unravels chunks lazily on the worker.
n_tasks = max(1, min(parallelism, n_chunks))
batch_size = math.ceil(n_chunks / n_tasks)
for flat_start in range(0, n_chunks, batch_size):
flat_stop = min(flat_start + batch_size, n_chunks)
chunk_range = _ChunkRange(
name, meta, chunks, grid, flat_start, flat_stop
)
read_tasks.append(
ReadTask(
_create_read_fn(
chunk_range, self.root, per_task_row_limit, retry_match
),
BlockMetadata(
num_rows=flat_stop - flat_start,
size_bytes=self._estimate_range_mem_size(chunk_range),
input_files=(self.paths[0],),
exec_stats=None,
),
per_task_row_limit=per_task_row_limit,
)
)
return read_tasks
def _estimate_range_mem_size(self, chunk_range: _ChunkRange) -> int:
"""Upper-bound in-memory bytes for a chunk-grid range.
Assumes a full-size chunk per index; trailing-edge chunks are smaller,
so this slightly over-estimates. O(1) -- it does not enumerate the range.
"""
n = chunk_range.flat_stop - chunk_range.flat_start
return n * math.prod(chunk_range.chunks) * chunk_range.meta.itemsize
def _get_aligned_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> List[ReadTask]:
"""Aligned read tasks. See :meth:`get_read_tasks` for semantics."""
assert self._aligned_array_names is not None
# All aligned arrays share the same axis-0 chunk size (validated in
# ``__init__``) and the same shape[0]. Read the geometry off the first.
first_name = self._aligned_array_names[0]
axis_0_chunk = self._array_chunks[first_name][0]
shape0 = self._metadata_by_path[first_name].shape[0]
descriptors = [
_AlignedChunkDescriptor(
chunk_index=i,
t_start=i * axis_0_chunk,
t_stop=min((i + 1) * axis_0_chunk, shape0),
t_stop_data=min((i + 1) * axis_0_chunk + self.overlap, shape0),
)
for i in range(math.ceil(shape0 / axis_0_chunk))
]
if not descriptors:
return []
n_tasks = max(1, min(parallelism, len(descriptors)))
batch_size = math.ceil(len(descriptors) / n_tasks)
read_tasks: List[ReadTask] = []
for start in range(0, len(descriptors), batch_size):
batch = descriptors[start : start + batch_size]
read_tasks.append(
ReadTask(
_create_aligned_read_fn(
batch,
self._aligned_array_names,
self.root,
per_task_row_limit,
retry_match,
),
BlockMetadata(
num_rows=len(batch),
size_bytes=self._estimate_aligned_batch_mem_size(batch),
input_files=(self.paths[0],),
exec_stats=None,
),
per_task_row_limit=per_task_row_limit,
)
)
return read_tasks
def _estimate_aligned_batch_mem_size(
self, batch: list[_AlignedChunkDescriptor]
) -> int:
"""Sum bytes across all (row, aligned-array) pairs in a wide-row batch.
Accounts for the trailing overlap data each row carries: the row's
per-array slice covers ``[t_start, t_stop_data)``, not just
``[t_start, t_stop)``.
"""
assert self._aligned_array_names is not None
return sum(
(desc.t_stop_data - desc.t_start)
* (math.prod(meta.shape[1:]) if len(meta.shape) > 1 else 1)
* meta.itemsize
for desc in batch
for meta in (
self._metadata_by_path[name] for name in self._aligned_array_names
)
)
def _load_metadata(self, array_paths) -> dict[str, ZarrArrayMeta]:
"""Read ``shape``/``chunks``/``dtype`` for the selected arrays off ``self.root``.
zarr validated the store's metadata when it was opened, so this only
adapts the resulting ``zarr.Array`` objects. Discovery uses consolidated
metadata when present, then explicit ``array_paths``, then an optional
full scan (``allow_full_metadata_scan``). If ``array_paths`` is given,
the discovered set is filtered down to it.
"""
import zarr
from zarr.util import normalize_storage_path
root = self.root
requested = (
{normalize_storage_path(p) for p in array_paths} if array_paths else None
)
if isinstance(root, zarr.Array):
# A store that is itself an array exposes exactly one path: "" (root).
# Reject any requested path that isn't the root so a bad ``array_paths``
# fails loudly here instead of silently returning the root array.
if requested is not None and requested != {""}:
raise ValueError(
f"This Zarr store is a single root-level array (path ''), "
f"but array_paths={array_paths!r} requested other path(s). "
f"Pass array_paths=[''] or omit it."
)
return {"": ZarrArrayMeta.from_zarr_array(root)}
if not self._consolidated and not self.allow_full_metadata_scan:
if requested is None:
raise ValueError(
"No array_paths were provided and this Zarr store does not "
"contain .zmetadata. Pass array_paths=[...] or set "
"allow_full_metadata_scan=True."
)
out: dict[str, ZarrArrayMeta] = {}
for raw in array_paths:
name = normalize_storage_path(raw)
try:
arr = root[name]
except KeyError as e:
raise ValueError(
f"Array path {raw!r} not found in Zarr store."
) from e
if not isinstance(arr, zarr.Array):
raise ValueError(f"Array path {raw!r} is a group, not an array.")
out[name] = ZarrArrayMeta.from_zarr_array(arr)
return out
all_arrays: dict[str, ZarrArrayMeta] = {}
def _collect(name: str, obj) -> None:
if isinstance(obj, zarr.Array):
all_arrays[name] = ZarrArrayMeta.from_zarr_array(obj)
root.visititems(_collect)
if requested is not None:
missing = sorted(requested - all_arrays.keys())
if missing:
raise ValueError(
f"Array(s) not found: {', '.join(repr(m) for m in missing)}. "
f"Available: {', '.join(repr(a) for a in sorted(all_arrays))}"
)
all_arrays = {k: v for k, v in all_arrays.items() if k in requested}
return all_arrays