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

482 lines
19 KiB
Python

import itertools
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, Iterable, Iterator, List, Optional, Tuple
import ray
from .common import NodeIdStr
from ray.data._internal.memory_tracing import trace_deallocation
from ray.data.block import (
Block,
BlockAccessor,
BlockMetadata,
Schema,
_take_first_non_empty_schema,
)
from ray.data.context import DataContext
from ray.types import ObjectRef
@dataclass(frozen=True)
class BlockSlice:
"""A slice of a block."""
# Starting row offset (inclusive) within the block.
start_offset: int
# Ending row offset (exclusive) within the block.
end_offset: int
@property
def num_rows(self) -> int:
return self.end_offset - self.start_offset
@dataclass(frozen=True, slots=True)
class BlockEntry:
"""One block delivery: the ref + the block's measured metadata.
Used as the element type of ``RefBundle.blocks`` (replaces the legacy
``(ObjectRef, BlockMetadata)`` 2-tuple shape). Naming the fields makes
every call site self-describing and reserves room for the bundle entry
to grow without disturbing the surrounding shape.
"""
ref: ObjectRef[Block]
metadata: BlockMetadata
@dataclass(frozen=True)
class RefBundle:
"""A group of data block references and their metadata.
Operators take in and produce streams of RefBundles.
Most commonly a RefBundle consists of a single block object reference.
In some cases, e.g., due to block splitting, or for a reduce task, there may
be more than one block.
Block bundles have ownership semantics, i.e., shared ownership (similar to C++
shared_ptr, multiple operators share the same block bundle), or unique ownership
(similar to C++ unique_ptr, only one operator owns the block bundle). This
allows operators to know whether they can destroy blocks when they don't need
them. Destroying blocks eagerly is more efficient than waiting for Python GC /
Ray reference counting to kick in.
"""
# Per-block entries. The size_bytes must be known in the metadata,
# num_rows is optional. Legacy ``(ref, metadata)`` 2-tuples are no longer
# accepted at construction and must be explicitly wrapped in ``BlockEntry``
# (``__post_init__`` rejects anything else with an actionable assertion).
blocks: Tuple[BlockEntry, ...]
# The schema of the blocks in this bundle. This is optional, and may be None
# if blocks are empty.
schema: Optional["Schema"]
# Whether we own the blocks (can safely destroy them).
owns_blocks: bool
# The slices of the blocks in this bundle. After __post_init__, this is always
# a list with length equal to len(blocks). Individual entries can be None to
# represent a full block (equivalent to BlockSlice(0, num_rows)).
# Pass None during construction to initialize all slices as None (full blocks).
slices: Optional[Tuple[Optional[BlockSlice], ...]] = None
# This attribute is used by the split() operator to assign bundles to logical
# output splits. It is otherwise None.
output_split_idx: Optional[int] = None
# Object metadata (size, locations, spilling status)
_cached_object_meta: Optional[Dict[ObjectRef, "_ObjectMetadata"]] = None
# Preferred locations for this bundle determined based on the locations
# of individual objects and their corresponding size, ie location with the
# largest total number of bytes present there has the highest preference.
_cached_preferred_locations: Optional[Dict[NodeIdStr, int]] = None
def __post_init__(self):
if self.schema is not None:
import pyarrow as pa
from ray.data._internal.pandas_block import PandasBlockSchema
assert isinstance(
self.schema, (pa.lib.Schema, PandasBlockSchema)
), f"Schema must be a pyarrow or PandasBlockSchema, got {type(self.schema)}"
if not isinstance(self.blocks, tuple):
object.__setattr__(self, "blocks", tuple(self.blocks))
for entry in self.blocks:
assert isinstance(entry, BlockEntry), (
f"RefBundle.blocks must contain BlockEntry instances; got {type(entry).__name__}. "
"Construct entries with `BlockEntry(ref=..., metadata=...)`."
)
if self.slices is None:
object.__setattr__(self, "slices", (None,) * len(self.blocks))
else:
if not isinstance(self.slices, tuple):
object.__setattr__(self, "slices", tuple(self.slices))
assert len(self.blocks) == len(
self.slices
), "Number of blocks and slices must match"
# Validate slice ranges
for entry, block_slice in zip(self.blocks, self.slices):
if block_slice is not None:
assert (
block_slice.start_offset >= 0
), f"Slice start_offset must be non-negative: {block_slice.start_offset}"
assert (
block_slice.end_offset >= block_slice.start_offset
), f"Slice end_offset must be >= start_offset: [{block_slice.start_offset}, {block_slice.end_offset})"
if entry.metadata.num_rows is not None:
assert (
block_slice.end_offset <= entry.metadata.num_rows
), f"Slice range [{block_slice.start_offset}, {block_slice.end_offset}) exceeds block num_rows: {entry.metadata.num_rows}"
for entry in self.blocks:
if entry.metadata.size_bytes is None:
raise ValueError(
"The size in bytes of the block must be known: {}".format(entry)
)
@property
def block_refs(self) -> List[ObjectRef[Block]]:
"""List of block references in this bundle."""
return [entry.ref for entry in self.blocks]
@property
def metadata(self) -> List[BlockMetadata]:
"""List of block metadata in this bundle."""
return [entry.metadata for entry in self.blocks]
def num_rows(self) -> Optional[int]:
"""Number of rows present in this bundle, if known.
Iterates through blocks and their corresponding slices to calculate the total.
Note: Block metadata always refers to the full block, not the slice.
- If block_slice is None, uses the full block's metadata.num_rows
- If block_slice is present, uses the slice's num_rows (partial block portion)
- Returns None if any full block has unknown row count (metadata.num_rows is None)
"""
total = 0
for metadata, block_slice in zip(self.metadata, self.slices):
if block_slice is None:
if metadata.num_rows is None:
return None
total += metadata.num_rows
else:
total += block_slice.num_rows
return total
def size_bytes(self) -> int:
"""Size of the blocks of this bundle in bytes.
Iterates through blocks and their corresponding slices to calculate the total size.
Note: Block metadata always refers to the full block, not the slice.
- If block_slice is None, uses the full block's metadata.size_bytes
- If block_slice is present but num_rows is unknown or zero, uses full metadata.size_bytes
- If block_slice represents a partial block, estimates size proportionally based on
(metadata.size_bytes / metadata.num_rows) * block_slice.num_rows
- Otherwise, uses the full metadata.size_bytes
"""
total = 0
for entry, block_slice in zip(self.blocks, self.slices):
metadata = entry.metadata
if block_slice is None:
# Full block
total += metadata.size_bytes
elif metadata.num_rows is None or metadata.num_rows == 0:
# Unknown num_rows or empty block - use full metadata size
total += metadata.size_bytes
elif metadata.num_rows != block_slice.num_rows:
# Partial block - estimate size based on rows
per_row = metadata.size_bytes / metadata.num_rows
total += max(1, round(per_row * block_slice.num_rows))
else:
total += metadata.size_bytes
return total
def destroy_if_owned(self) -> int:
"""Clears the object store memory for these blocks if owned.
Returns:
The number of bytes freed.
"""
should_free = self.owns_blocks and DataContext.get_current().eager_free
for block_ref in self.block_refs:
trace_deallocation(
block_ref, "RefBundle.destroy_if_owned", free=should_free
)
return self.size_bytes() if should_free else 0
def get_preferred_object_locations(self) -> Dict[NodeIdStr, int]:
"""Returns a mapping of node IDs to total bytes stored on each node.
Returns:
Dict mapping node ID to total bytes stored on that node
"""
meta = self._get_cached_metadata()
if self._cached_preferred_locations is None:
preferred_locs: Dict[NodeIdStr, int] = defaultdict(int)
for ref, obj_meta in meta.items():
for loc in obj_meta.locs:
preferred_locs[loc] += obj_meta.size
# NOTE: We're working around object being immutable to update cached
# values (safe)
object.__setattr__(self, "_cached_preferred_locations", preferred_locs)
return self._cached_preferred_locations
def _get_cached_metadata(self) -> Dict[ObjectRef, "_ObjectMetadata"]:
if self._cached_object_meta is None:
# This call is pretty fast for owned objects (~5k/s), so we don't need to
# batch it for now.
meta = ray.experimental.get_local_object_locations(self.block_refs)
# Extract locations
object_metas: Dict[ObjectRef, _ObjectMetadata] = {
ref: _ObjectMetadata(
size=meta[ref]["object_size"],
spilled=meta[ref]["did_spill"],
locs=meta[ref]["node_ids"],
)
for ref in self.block_refs
}
# NOTE: We're working around object being immutable to update cached
# values (safe)
object.__setattr__(self, "_cached_object_meta", object_metas)
return self._cached_object_meta
def slice(self, needed_rows: int) -> Tuple["RefBundle", "RefBundle"]:
"""Slice a Ref Bundle into the first bundle containing the first `needed_rows` rows and the remaining bundle containing the remaining rows.
Args:
needed_rows: Number of rows to take from the head of the bundle.
Returns:
A tuple of (sliced_bundle, remaining_bundle). The needed rows must be less than the number of rows in the bundle.
"""
assert needed_rows > 0, "needed_rows must be positive."
assert (
self.num_rows() is not None
), "Cannot slice a RefBundle with unknown number of rows."
assert (
needed_rows < self.num_rows()
), f"To slice a RefBundle, the number of requested rows must be less than the number of rows in the bundle. Requested {needed_rows} rows but bundle only has {self.num_rows()} rows."
block_slices = []
for metadata, block_slice in zip(self.metadata, self.slices):
if block_slice is None:
# None represents a full block, convert to explicit BlockSlice
assert (
metadata.num_rows is not None
), "Cannot derive block slice for a RefBundle with unknown block row counts."
block_slices.append(
BlockSlice(start_offset=0, end_offset=metadata.num_rows)
)
else:
block_slices.append(block_slice)
consumed_blocks: List[BlockEntry] = []
consumed_slices: List[BlockSlice] = []
remaining_blocks: List[BlockEntry] = []
remaining_slices: List[BlockSlice] = []
rows_to_take = needed_rows
for entry, block_slice in zip(self.blocks, block_slices):
block_rows = block_slice.num_rows
if rows_to_take >= block_rows:
consumed_blocks.append(entry)
consumed_slices.append(block_slice)
rows_to_take -= block_rows
else:
if rows_to_take == 0:
remaining_blocks.append(entry)
remaining_slices.append(block_slice)
continue
consume_slice = BlockSlice(
start_offset=block_slice.start_offset,
end_offset=block_slice.start_offset + rows_to_take,
)
consumed_blocks.append(entry)
consumed_slices.append(consume_slice)
leftover_rows = block_rows - rows_to_take
if leftover_rows > 0:
remainder_slice = BlockSlice(
start_offset=consume_slice.end_offset,
end_offset=block_slice.end_offset,
)
remaining_blocks.append(entry)
remaining_slices.append(remainder_slice)
rows_to_take = 0
sliced_bundle = RefBundle(
blocks=tuple(consumed_blocks),
schema=self.schema,
owns_blocks=False,
slices=tuple(consumed_slices) if consumed_slices else None,
)
remaining_bundle = RefBundle(
blocks=tuple(remaining_blocks),
schema=self.schema,
owns_blocks=False,
slices=tuple(remaining_slices) if remaining_slices else None,
)
return sliced_bundle, remaining_bundle
@classmethod
def merge_ref_bundles(cls, bundles: Iterable["RefBundle"]) -> "RefBundle":
"""Merge multiple RefBundles into a single RefBundle.
Args:
bundles: An iterable of RefBundles to merge.
Returns:
A single RefBundle containing all blocks from the input bundles.
owns_blocks is True only if all input bundles own their blocks.
schema is the first non-empty schema found.
"""
bundles = list(bundles)
if not bundles:
return cls(blocks=(), owns_blocks=True, schema=None)
merged_blocks = list(
itertools.chain.from_iterable(bundle.blocks for bundle in bundles)
)
merged_slices = list(
itertools.chain.from_iterable(bundle.slices for bundle in bundles)
)
# Ray Data uses the `owns_blocks` flag to determine if the system can eagerly
# destroy blocks when they're no longer needed. To be safe, we only set this
# to True if all input bundles own their blocks.
owns_blocks = all(bundle.owns_blocks for bundle in bundles)
# TODO: Reconcile the schemas rather than taking the first non-empty schema.
schema = _take_first_non_empty_schema(bundle.schema for bundle in bundles)
return cls(
blocks=tuple(merged_blocks),
schema=schema,
owns_blocks=owns_blocks,
slices=merged_slices,
)
def __eq__(self, other: "RefBundle"):
if self is other:
return True
elif not isinstance(other, RefBundle):
return False
return (
self.blocks == other.blocks
and self.slices == other.slices
# NOTE: We're establishing a requirement of schemas for `RefBundle`
# to be exactly the same object for it to be considered equal.
#
# This is necessary to avoid a full schema equality check that
# is computationally intensive.
and self.schema is other.schema
and self.owns_blocks == other.owns_blocks
and self.output_split_idx == other.output_split_idx
)
def __hash__(self) -> int:
return hash(
(
# Only hash block refs
*[entry.ref for entry in self.blocks],
*self.slices,
# Check out comment in ``__eq__``
id(self.schema),
self.owns_blocks,
self.output_split_idx,
)
)
def __len__(self) -> int:
return len(self.blocks)
def __str__(self) -> str:
lines = [
f"RefBundle({len(self.blocks)} blocks,",
f" {self.num_rows()} rows,",
f" schema={self.schema},",
f" owns_blocks={self.owns_blocks},",
" blocks=(",
]
# Loop through each block and show details
for i, (entry, block_slice) in enumerate(zip(self.blocks, self.slices)):
metadata = entry.metadata
row_str = (
f"{metadata.num_rows} rows"
if metadata.num_rows is not None
else "unknown rows"
)
bytes_str = f"{metadata.size_bytes} bytes"
slice_str = (
f"slice={block_slice}"
if block_slice is not None
else "slice=None (full block)"
)
lines.append(f" {i}: {row_str}, {bytes_str}, {slice_str}")
lines.append(" )")
lines.append(")")
return "\n".join(lines)
@dataclass
class _ObjectMetadata:
# Object size in bytes
size: int
# Flag whether object has been spilled
spilled: bool
# List of nodes object exists on
locs: List[NodeIdStr] = None
def _ref_bundles_iterator_to_block_refs_list(
ref_bundles: Iterator[RefBundle],
) -> List[ObjectRef[Block]]:
"""Convert an iterator of RefBundles to a list of Block object references."""
return [
block_ref for ref_bundle in ref_bundles for block_ref in ref_bundle.block_refs
]
def _iter_sliced_blocks(
blocks: Iterable[Block],
slices: List[Optional[BlockSlice]],
) -> Iterator[Block]:
blocks_list = list(blocks)
for block, block_slice in zip(blocks_list, slices):
if block_slice is None:
# None represents a full block - yield it as is
yield block
else:
accessor = BlockAccessor.for_block(block)
start = block_slice.start_offset
end = block_slice.end_offset
assert start <= end, "start must be less than end"
assert start >= 0, "start must be non-negative"
assert (
end <= accessor.num_rows()
), "end must be less than or equal to the number of rows in the block"
yield accessor.slice(start, end, copy=False)