chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,3 @@
from ray.data._internal.block_batching.block_batching import batch_blocks
__all__ = ["batch_blocks"]
@@ -0,0 +1,101 @@
from typing import Callable, Iterator, Optional, TypeVar
from ray.data._internal.block_batching.interfaces import ResolvedBlock
from ray.data._internal.block_batching.util import (
_MappingIterator,
blocks_to_batches,
collate,
format_batches,
)
from ray.data._internal.stats import DatasetStats
from ray.data.block import Block, DataBatch
T = TypeVar("T")
def batch_blocks(
blocks: Iterator[Block],
*,
stats: Optional[DatasetStats] = None,
batch_size: Optional[int] = None,
batch_format: str = "default",
drop_last: bool = False,
collate_fn: Optional[Callable[[DataBatch], DataBatch]] = None,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
) -> Iterator[DataBatch]:
"""Create formatted batches of data from 1 or more blocks.
This function takes in an iterator of already fetched blocks. Consequently, this
function doesn't support block prefetching.
"""
# TODO: make stage timings optional at _BatchingIterator so this
# shim can be removed. map() avoids holding block references.
wrapped_blocks = map(lambda b: ResolvedBlock(block=b), blocks)
# Build the processing pipeline
batch_iter = format_batches(
blocks_to_batches(
block_iter=wrapped_blocks,
stats=stats,
batch_size=batch_size,
drop_last=drop_last,
shuffle_buffer_min_size=shuffle_buffer_min_size,
shuffle_seed=shuffle_seed,
ensure_copy=ensure_copy,
),
batch_format=batch_format,
stats=stats,
ensure_copy=ensure_copy,
)
if collate_fn is not None:
batch_iter = collate(batch_iter, collate_fn=collate_fn, stats=stats)
return _UserTimingIterator(
_MappingIterator(batch_iter, lambda batch: batch.data), stats
)
class _UserTimingIterator(Iterator[DataBatch]):
def __init__(self, iter: Iterator[DataBatch], stats: Optional[DatasetStats]):
self._iter = iter
self._stats = stats
self._active_timer = None
def __iter__(self) -> Iterator[DataBatch]:
return self
def __next__(self) -> DataBatch:
# Since we're tracking time spent in user-code, we stop
# the timer immediately when `__next__` is called
self._stop_timer()
try:
res = next(self._iter)
# Reset timer and return
#
# NOTE: It's crucial that we reset the timer only after we
# retrieved the result to avoid starting the timer before
# we retrieve the next value
self._reset_timer()
return res
except StopIteration:
self._stop_timer()
raise
def _stop_timer(self):
if not self._stats:
return
if self._active_timer:
self._active_timer.__exit__(None, None, None)
self._active_timer = None
def _reset_timer(self):
if not self._stats:
return
self._active_timer = self._stats.iter_user_s.timer()
self._active_timer.__enter__()
@@ -0,0 +1,134 @@
import abc
from dataclasses import dataclass, field
from typing import Any, Iterable, List, Optional, Tuple
from ray.data._internal.stats import IterationStage, TimeSpan
from ray.data.block import Block, DataBatch
from ray.types import ObjectRef
@dataclass
class BlockStageTimings:
"""Per-block timing for production_wait + data_transfer.
Both fields are always populated when ``stage_timings`` is set on a
``ResolvedBlock``; the outer ``ResolvedBlock.stage_timings`` Optional
encodes "no timing recorded" (e.g. blocks already resolved before
entering the pipeline).
"""
production_wait: TimeSpan
data_transfer: TimeSpan
@dataclass
class ResolvedBlock:
"""A resolved block paired with its per-block stage timings.
``stage_timings`` is None when no timing was recorded (e.g. blocks
already resolved before entering the pipeline).
"""
block: Block
stage_timings: Optional[BlockStageTimings] = None
@dataclass
class BatchStageTimings:
"""Per-batch timing windows for each iteration stage.
Fetch stages (production_wait, data_transfer) accumulate one span per
block, so they are ``List[TimeSpan]``. Other stages run at most once
per batch, so they are ``Optional[TimeSpan]``. ``stages()`` yields
``List[TimeSpan]`` for all stages (single spans wrapped in a 1-element
list) so ``_attribute_blocked_time`` can use uniform overlap logic.
"""
production_wait: List[TimeSpan] = field(default_factory=list)
data_transfer: List[TimeSpan] = field(default_factory=list)
batching: Optional[TimeSpan] = None
format: Optional[TimeSpan] = None
collate: Optional[TimeSpan] = None
finalize: Optional[TimeSpan] = None
def stages(self) -> Iterable[Tuple[IterationStage, List[TimeSpan]]]:
"""Yield (stage, spans) pairs, wrapping single spans in a list."""
return (
(IterationStage.PRODUCTION_WAIT, self.production_wait),
(IterationStage.DATA_TRANSFER, self.data_transfer),
(
IterationStage.BATCHING,
[self.batching] if self.batching is not None else [],
),
(IterationStage.FORMAT, [self.format] if self.format is not None else []),
(
IterationStage.COLLATE,
[self.collate] if self.collate is not None else [],
),
(
IterationStage.FINALIZE,
[self.finalize] if self.finalize is not None else [],
),
)
def accumulate_block_timings(self, src: BlockStageTimings) -> None:
"""Accumulate a block's fetch timings into this batch's lists.
A boundary block whose rows span multiple batches is attributed
to the first batch it lands in.
"""
self.production_wait.append(src.production_wait)
self.data_transfer.append(src.data_transfer)
@dataclass
class BatchMetadata:
"""Metadata associated with a batch.
Attributes:
batch_idx: The global index of this batch so that downstream operations can
maintain ordering.
num_rows: Number of rows in this batch (for ``iter_rows_total``).
stage_timings: Per-stage timing windows.
"""
batch_idx: int
num_rows: int = 0
stage_timings: BatchStageTimings = field(default_factory=BatchStageTimings)
@dataclass
class Batch:
"""A batch of data.
Attributes:
metadata: Metadata associated with this batch.
data: The batch of data.
"""
metadata: BatchMetadata
data: DataBatch
class CollatedBatch(Batch):
"""A batch of collated data.
Attributes:
data: The batch of data which is the output of a user provided collate_fn
Therefore, the type of this data can be Any.
"""
data: Any
class BlockPrefetcher(metaclass=abc.ABCMeta):
"""Interface for prefetching blocks."""
@abc.abstractmethod
def prefetch_blocks(self, blocks: List[ObjectRef[Block]]):
"""Prefetch the provided blocks to this node."""
pass
def stop(self):
"""Stop prefetching and release resources."""
pass
@@ -0,0 +1,558 @@
import collections
import time
from contextlib import contextmanager, nullcontext
from typing import Any, Callable, Dict, Iterator, List, Optional
import ray
from ray._common.utils import env_integer
from ray.data._internal.block_batching.interfaces import (
Batch,
BlockPrefetcher,
)
from ray.data._internal.block_batching.util import (
ActorBlockPrefetcher,
WaitBlockPrefetcher,
blocks_to_batches,
collate,
finalize_batches,
format_batches,
iter_threaded,
resolve_block_refs,
)
from ray.data._internal.execution.interfaces.ref_bundle import RefBundle
from ray.data._internal.memory_tracing import trace_deallocation
from ray.data._internal.stats import DatasetStats, TimeSpan, _StatsManager
from ray.data.block import Block, DataBatch
from ray.data.context import DataContext
from ray.types import ObjectRef
DEFAULT_FORMAT_THREADPOOL_NUM_WORKERS = env_integer(
"RAY_DATA_MAX_FORMAT_THREADPOOL_NUM_WORKERS", 4
)
def _merged_duration(
spans: List["TimeSpan"], blocked_start_s: float, blocked_end_s: float
) -> float:
"""Total time ``spans`` overlap with ``[blocked_start_s, blocked_end_s]``,
with overlapping spans merged so nothing is double-counted."""
intervals = []
for s in spans:
lo = max(s.start_s, blocked_start_s)
hi = min(s.end_s, blocked_end_s)
if hi > lo:
intervals.append((lo, hi))
if not intervals:
return 0.0
intervals.sort()
merged = [intervals[0]]
for i in range(1, len(intervals)):
lo, hi = intervals[i]
if lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
else:
merged.append((lo, hi))
return sum(hi - lo for lo, hi in merged)
class BatchIterator:
"""Defines an iterator pipeline to convert a stream of block object references
into a stream of formatted batches ready to be consumed by the user.
This takes a block iterator and creates batch_size batches, slicing,
unioning, shuffling, prefetching, and formatting blocks as needed.
This involves both pipeline parallelism (e.g. prefetching)
and data parallelism (e.g. threadpool operations):
If prefetch_batches=2, these are all the batches in flight:
[User thread] trains on Batch 0
- [Fetch thread] Batch 1 finalization + move to output queue
- [Worker thread 1] Batch 2 formatting + collating
- [Worker thread 2] Batch 3 formatting + collating
- [Raylet] Batches 4 + 5 fetched to local object store memory
At any point in time there are prefetch_batches+1 batches in local heap memory.
And the next set of prefetch_batches in local object store memory.
The actual steps are as follows:
In a single async thread, do the following:
1. Trigger Ray local prefetching of `prefetch_batches` worth of block object
references.
2. Resolve (i.e. call `ray.get()`) on the block references.
3. Perform the necessary batch slicing to construct full batches, possibly
shuffling if necessary.
4. Then, in a threadpool consisting of `prefetch_batches` threads:
a. Format the batches to the provided batch format.
b. Apply the collate function.
5. If preserve_order, restore the original batch order from the
threadpool output.
6. Finalize each of the (now ordered) collated batches.
Args:
ref_bundles: An iterator over RefBundles.
stats: DatasetStats object to record timing and other statistics.
dataset_tag: The tag of the dataset to record timing and other statistics.
clear_block_after_read: Whether to clear the block from object store
manually (i.e. without waiting for Python's automatic GC) after it
is read. Doing so will reclaim memory faster and hence reduce the
memory footprint. However, the caller has to ensure the safety, i.e.
the block will never be accessed again.
batch_size: Record batch size, or None to let the system pick.
batch_format: The format in which to return each batch.
Specify "default" to use the current block format (promoting
Arrow to pandas automatically), "pandas" to
select ``pandas.DataFrame`` or "pyarrow" to select
``pyarrow.Table``, or None to use entire blocks
as batches. Default is "default".
drop_last: Whether to drop the last batch if it's incomplete.
collate_fn: A function to apply to each data batch before returning it.
finalize_fn: A function to apply to each data batch after it has been collated.
This function is not run in a threadpool so it can be used for
memory-intensive operations such as GPU preloading.
shuffle_buffer_min_size: If non-None, the data will be randomly shuffled using a
local in-memory shuffle buffer, and this value will serve as the minimum
number of rows that must be in the local in-memory shuffle buffer in order
to yield a batch.
shuffle_seed: The seed to use for the local random shuffle.
ensure_copy: Whether batches are always copied from the underlying base
blocks (not zero-copy views).
prefetch_batches: The number of batches to fetch ahead of the current batch to
process. If set to greater than 0, a separate thread will be used to fetch
the specified amount of formatted batches from blocks. This improves
performance for non-CPU bound UDFs, allowing batch fetching compute and
formatting to be overlapped with the UDF. Defaults to 1.
prefetch_bytes_callback: A callback to report prefetched bytes to the executor's
resource manager.
preserve_order: Whether to maintain the original order that the batches
were formed from the blocks (e.g., the input block order).
This only takes effect in the case that the format/collate threadpool
has more than one thread and the output batches have non-deterministic
order.
"""
UPDATE_METRICS_INTERVAL_S: float = 5.0
def __init__(
self,
ref_bundles: Iterator[RefBundle],
*,
stats: Optional[DatasetStats] = None,
dataset_tag: Optional[str] = None,
clear_block_after_read: bool = False,
batch_size: Optional[int] = None,
batch_format: Optional[str] = "default",
drop_last: bool = False,
collate_fn: Optional[Callable[[DataBatch], Any]] = None,
finalize_fn: Optional[Callable[[Any], Any]] = None,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
prefetch_batches: int = 1,
prefetch_bytes_callback: Optional[Callable[[int], None]] = None,
preserve_order: bool = False,
):
self._ref_bundles = ref_bundles
self._stats = stats
self._dataset_tag = dataset_tag
self._batch_size = batch_size
self._batch_format = batch_format
self._drop_last = drop_last
self._collate_fn = collate_fn
self._finalize_fn = finalize_fn
self._shuffle_buffer_min_size = shuffle_buffer_min_size
self._shuffle_seed = shuffle_seed
self._ensure_copy = ensure_copy
self._prefetch_batches = prefetch_batches
self._prefetch_bytes_callback = prefetch_bytes_callback
self._preserve_order = preserve_order
self._eager_free = (
clear_block_after_read and DataContext.get_current().eager_free
)
actor_prefetcher_enabled = (
prefetch_batches > 0
and DataContext.get_current().actor_prefetcher_enabled
and not ray.util.client.ray.is_connected()
)
self._prefetcher = (
ActorBlockPrefetcher()
if actor_prefetcher_enabled
else WaitBlockPrefetcher()
)
self._yielded_first_batch = False
# This stores the last time we updated the metrics.
# This allows us to update metrics on some interval,
# by comparing it with the current timestamp.
self._metrics_last_updated: float = 0.0
def _prefetch_blocks(
self, ref_bundles: Iterator[RefBundle]
) -> Iterator[ObjectRef[Block]]:
return prefetch_batches_locally(
ref_bundles=ref_bundles,
prefetcher=self._prefetcher,
num_batches_to_prefetch=self._prefetch_batches,
batch_size=self._batch_size,
eager_free=self._eager_free,
stats=self._stats,
)
def _resolve_block_refs(
self, block_refs: Iterator[ObjectRef[Block]]
) -> Iterator[Any]:
return resolve_block_refs(block_ref_iter=block_refs, stats=self._stats)
def _blocks_to_batches(self, blocks: Iterator[Block]) -> Iterator[Batch]:
return blocks_to_batches(
block_iter=blocks,
stats=self._stats,
batch_size=self._batch_size,
drop_last=self._drop_last,
shuffle_buffer_min_size=self._shuffle_buffer_min_size,
shuffle_seed=self._shuffle_seed,
ensure_copy=self._ensure_copy,
)
def _format_batches(self, batches: Iterator[Batch]) -> Iterator[Batch]:
num_threadpool_workers = min(
DEFAULT_FORMAT_THREADPOOL_NUM_WORKERS, self._prefetch_batches
)
return _format_in_threadpool(
batch_iter=batches,
stats=self._stats,
batch_format=self._batch_format,
collate_fn=self._collate_fn,
num_threadpool_workers=num_threadpool_workers,
ensure_copy=self._ensure_copy,
)
def _finalize_batches(
self,
batch_iter: Iterator[Batch],
) -> Iterator[Batch]:
if self._finalize_fn is None:
return batch_iter
return finalize_batches(
batch_iter, finalize_fn=self._finalize_fn, stats=self._stats
)
def _restore_original_batch_order(
self, batches: Iterator[Batch]
) -> Iterator[Batch]:
return restore_original_order(batches)
def _pipeline(self, ref_bundles: Iterator[RefBundle]) -> Iterator[Batch]:
# Step 1: Prefetch logical batches locally.
block_iter = self._prefetch_blocks(ref_bundles)
# Step 2: Resolve the blocks.
block_iter = self._resolve_block_refs(block_iter)
# Step 3: Batch and shuffle the resolved blocks.
batch_iter = self._blocks_to_batches(block_iter)
# Step 4: Format and collate the batches in a threadpool.
batch_iter = self._format_batches(batch_iter)
# Step 5 (optional): Restore the original order of the batches
# if preserve_order is True, in the case that the format/collate threadpool
# shuffles around the batches non-deterministically.
# NOTE: This should happen before finalize_fn so the reorder buffer
# holds CPU batches rather than finalize_fn outputs (e.g., GPU tensors).
if self._preserve_order:
batch_iter = self._restore_original_batch_order(batch_iter)
# Step 6: Finalize the batches (e.g., move to GPU).
batch_iter = self._finalize_batches(batch_iter)
yield from batch_iter
def _iter_batches(self) -> Iterator[DataBatch]:
"""Pull batches from the pipeline and yield batch data.
Captures the training thread's blocked window around each ``next()``
call and attributes it to pipeline stages via
``_attribute_blocked_time``.
"""
batch_iter = iter_threaded(self._ref_bundles, fn=self._pipeline)
self.before_epoch_start()
while True:
with self.get_next_batch_context():
blocked_start_s = time.perf_counter()
try:
batch = next(batch_iter)
except StopIteration:
break
blocked_end_s = time.perf_counter()
self._attribute_blocked_time(batch, blocked_start_s, blocked_end_s)
with self.yield_batch_context(batch):
yield batch.data
self.after_epoch_end()
def _attribute_blocked_time(
self, batch: Batch, blocked_start_s: float, blocked_end_s: float
) -> None:
"""Attribute per-stage blocked time via overlap with the training window.
Each stage's spans on ``batch.metadata.stage_timings`` are intersected
with the training thread's blocked window ``[blocked_start_s,
blocked_end_s]``. Overlapping spans are merged first, so the result
is the total time the stage was active during the stall (no
double-counting).
Limitation: only the yielded batch's spans are attributed. Other
in-flight batches (being processed by background threads) may also
overlap with the training stall window but are not counted.
TODO: track in-flight batches and union their spans for complete
attribution. The current implementation suffices for capturing
data-loading bottlenecks.
TODO: reorder buffer wait under ``preserve_order`` is unattributed
(per-stage spans are recorded at format/collate completion, before
the batch leaves ``restore_original_order``).
Args:
batch: Batch whose per-stage timings should be attributed.
blocked_start_s: perf_counter() just before next().
blocked_end_s: perf_counter() just after next() returned.
"""
if self._stats is None:
return
timings = batch.metadata.stage_timings
for stage, spans in timings.stages():
overlap_s = _merged_duration(spans, blocked_start_s, blocked_end_s)
if overlap_s > 0:
self._stats.get_blocked_timer(stage).add(overlap_s)
self._stats.iter_batches_total += 1
self._stats.iter_rows_total += batch.metadata.num_rows
def __iter__(self) -> Iterator[DataBatch]:
return self._iter_batches()
def before_epoch_start(self):
self._yielded_first_batch = False
def after_epoch_end(self):
# Report 0 prefetched bytes at the end of iteration.
if self._prefetch_bytes_callback is not None:
self._prefetch_bytes_callback(0)
if self._stats is None:
return
_StatsManager.update_iteration_metrics(self._stats, self._dataset_tag)
@contextmanager
def get_next_batch_context(self):
"""Context around ``next(batch_iter)``: tracks total blocked time
and time-to-first-batch."""
try:
if self._stats:
# Always track total blocked time
total_timer = self._stats.iter_total_blocked_s.timer()
# Also track the time until the first batch is ready
first_batch_ready_timer = (
self._stats.iter_time_to_first_batch_s.timer()
if not self._yielded_first_batch
else nullcontext()
)
with total_timer, first_batch_ready_timer:
yield
else:
yield
finally:
self._yielded_first_batch = True
@contextmanager
def yield_batch_context(self, batch: Batch):
"""Context around yielding a batch to the user: tracks user time
and periodically flushes metrics."""
with self._stats.iter_user_s.timer() if self._stats else nullcontext():
yield
# Report prefetched bytes to the executor's resource manager.
if self._prefetch_bytes_callback is not None and self._stats is not None:
self._prefetch_bytes_callback(self._stats.iter_prefetched_bytes)
if self._stats is None:
return
now = time.time()
if (now - self._metrics_last_updated) > self.UPDATE_METRICS_INTERVAL_S:
_StatsManager.update_iteration_metrics(self._stats, self._dataset_tag)
self._metrics_last_updated = now
def _format_in_threadpool(
batch_iter: Iterator[Batch],
stats: DatasetStats,
batch_format: Optional[str],
collate_fn: Optional[Callable[[DataBatch], Any]],
num_threadpool_workers: int,
ensure_copy: bool = False,
) -> Iterator[Batch]:
"""Executes the batching, formatting, and collation logic in a threadpool.
Args:
batch_iter: An iterator over logical batches.
stats: DatasetStats object to record timing and other statistics.
batch_format: The format in which to return each batch.
Specify "default" to use the current block format (promoting
Arrow to pandas automatically), "pandas" to
select ``pandas.DataFrame`` or "pyarrow" to select
``pyarrow.Table``, or None to use entire blocks
as batches.
collate_fn: A function to apply to each data batch before returning it.
num_threadpool_workers: The number of threads to use in the threadpool.
ensure_copy: Whether batches are always copied from the underlying base
blocks (not zero-copy views).
Returns:
An iterator over batches with formatting and collation applied.
"""
def threadpool_computations_format_collate(
batch_iter: Iterator[Batch],
) -> Iterator[Batch]:
# Step 4a: Format the batches.
formatted_batch_iter = format_batches(
batch_iter, batch_format=batch_format, stats=stats, ensure_copy=ensure_copy
)
# Step 4b: Apply the collate function if applicable.
if collate_fn is not None:
formatted_batch_iter = collate(
formatted_batch_iter, collate_fn=collate_fn, stats=stats
)
return formatted_batch_iter
if num_threadpool_workers > 0:
# Output order is non-deterministic across workers and is restored
# downstream by `restore_original_order`.
collated_iter = iter_threaded(
base_iterator=batch_iter,
fn=threadpool_computations_format_collate,
num_workers=num_threadpool_workers,
output_buffer_size=num_threadpool_workers,
)
else:
collated_iter = threadpool_computations_format_collate(batch_iter)
return collated_iter
def prefetch_batches_locally(
ref_bundles: Iterator[RefBundle],
prefetcher: BlockPrefetcher,
num_batches_to_prefetch: int,
batch_size: Optional[int],
eager_free: bool = False,
stats: Optional[DatasetStats] = None,
) -> Iterator[ObjectRef[Block]]:
"""Given an iterator of batched RefBundles, returns an iterator over the
corresponding block references while prefetching `num_batches_to_prefetch`
batches in advance.
Args:
ref_bundles: An iterator over batched RefBundles.
prefetcher: The prefetcher to use.
num_batches_to_prefetch: The number of batches to prefetch ahead of the
current batch during the scan.
batch_size: User specified batch size, or None to let the system pick.
eager_free: Whether to eagerly free the object reference from the object store.
stats: Dataset stats object used to store ref bundle retrieval time.
Yields:
Block: Block references (as ObjectRefs), in order.
"""
def get_next_ref_bundle() -> RefBundle:
with stats.iter_get_ref_bundles_s.timer() if stats else nullcontext():
return next(ref_bundles)
sliding_window = collections.deque()
current_window_size = 0
if num_batches_to_prefetch <= 0:
if stats:
stats.iter_prefetched_bytes = 0
for ref_bundle in ref_bundles:
for block_ref in ref_bundle.block_refs:
yield block_ref
return
if batch_size is not None:
num_rows_to_prefetch = num_batches_to_prefetch * batch_size
else:
num_rows_to_prefetch = None
# Create and fetch the initial window.
# Stop adding if the number of rows in this window is greater than requested
# batch size, or if the batch size is None and the number of blocks in this window
# is greater than requested batches to prefetch.
while (batch_size is not None and current_window_size < num_rows_to_prefetch) or (
batch_size is None and len(sliding_window) < num_batches_to_prefetch
):
try:
next_ref_bundle = get_next_ref_bundle()
sliding_window.extend(next_ref_bundle.blocks)
current_window_size += next_ref_bundle.num_rows()
except StopIteration:
break
prefetcher.prefetch_blocks([entry.ref for entry in sliding_window])
if stats:
stats.iter_prefetched_bytes = sum(
entry.metadata.size_bytes or 0 for entry in sliding_window
)
while sliding_window:
entry = sliding_window.popleft()
current_window_size -= entry.metadata.num_rows
if batch_size is None or current_window_size < num_rows_to_prefetch:
try:
next_ref_bundle = get_next_ref_bundle()
for next_entry in next_ref_bundle.blocks:
sliding_window.append(next_entry)
current_window_size += next_entry.metadata.num_rows
prefetcher.prefetch_blocks([entry.ref for entry in sliding_window])
except StopIteration:
pass
if stats:
stats.iter_prefetched_bytes = sum(
entry.metadata.size_bytes or 0 for entry in sliding_window
)
yield entry.ref
trace_deallocation(entry.ref, loc="iter_batches", free=eager_free)
prefetcher.stop()
def restore_original_order(batch_iter: Iterator[Batch]) -> Iterator[Batch]:
"""Restores the original order of the provided `batch_iter`
This function will yield items from `base_iterator` in the correct order based on
each batch's batch_idx. All indexes are expected to be unique.
`batch_iter` is expected to not have any missing indexes. All indexes from 0 to len
(base_iterator) must be present.
"""
next_index_required = 0
buffer: Dict[int, Batch] = {}
for batch in batch_iter:
assert batch.metadata.batch_idx not in buffer
buffer[batch.metadata.batch_idx] = batch
while next_index_required in buffer:
yield buffer.pop(next_index_required)
next_index_required += 1
while next_index_required in buffer:
yield buffer.pop(next_index_required)
next_index_required += 1
@@ -0,0 +1,561 @@
import dataclasses
import functools
import logging
import queue
import threading
import time
from typing import (
Any,
Callable,
Generator,
Generic,
Iterator,
List,
Optional,
Tuple,
TypeVar,
)
import ray
from ray.actor import ActorHandle
from ray.data._internal.batcher import Batcher, ShufflingBatcher
from ray.data._internal.block_batching.interfaces import (
Batch,
BatchMetadata,
BatchStageTimings,
BlockPrefetcher,
BlockStageTimings,
CollatedBatch,
ResolvedBlock,
)
from ray.data._internal.stats import DatasetStats, TimeSpan, _maybe_time
from ray.data.block import Block, BlockAccessor, DataBatch
from ray.types import ObjectRef
logger = logging.getLogger(__name__)
T = TypeVar("T")
U = TypeVar("U")
I = TypeVar("I")
O = TypeVar("O")
_SENTINEL = object()
def iter_threaded(
base_iterator: Iterator[T],
fn: Callable[[Iterator[T]], Iterator[U]],
num_workers: int = 1,
output_buffer_size: int = 1,
) -> Generator[U, None, None]:
"""Apply ``fn`` to ``base_iterator`` across ``num_workers`` background
threads, yielding results through a bounded queue.
Workers share ``base_iterator`` under a lock (so it may be a stateful,
non-thread-safe generator) and run ``fn`` concurrently. With
``num_workers > 1`` the output order is not preserved and must be restored
downstream by the consumer.
Invariant: the number of output-queue items + items in-flight in workers is
bounded by ``output_buffer_size``.
Workers reserve an output buffer slot before pulling from ``fn``, ensuring
they don't run ``fn`` (and hold the result) while waiting for queue space.
When the consumer stops early (``break``, ``.close()``, or GC), workers
are signaled via a stop event so they don't leak. Note: a hanging
``fn`` cannot be interrupted, so ``fn`` must terminate or raise within
bounded time per element. For example, the user function should have
timeouts if doing blocking I/O.
Args:
base_iterator: Iterator consumed (under a lock) by the workers.
fn: Transform applied by each worker to its view of ``base_iterator``.
num_workers: Number of background worker threads.
output_buffer_size: Max number of items held by the output-queue
+ in-flight in the workers.
"""
if num_workers < 1:
raise ValueError("num_workers must be at least 1.")
if output_buffer_size < 1:
raise ValueError("output_buffer_size must be at least 1.")
stopped = threading.Event()
result_queue: queue.Queue = queue.Queue()
slots = threading.Semaphore(output_buffer_size)
iter_lock = threading.Lock()
def _locked_iter() -> Iterator[T]:
while True:
with iter_lock:
if stopped.is_set():
return
try:
item = next(base_iterator)
except StopIteration:
return
yield item
def _acquire_slot() -> bool:
# Block until a slot is acquired or the consumer has stopped.
while not stopped.is_set():
if slots.acquire(timeout=0.1):
return True
return False
remaining_workers = num_workers
remaining_lock = threading.Lock()
def _worker():
nonlocal remaining_workers
slot_acquired = False
try:
# Construct `fn_iter` inside the try so any exception during
# construction propagates to the consumer via the outer except.
fn_iter = fn(_locked_iter())
while True:
slot_acquired = _acquire_slot()
if not slot_acquired:
break
item = next(fn_iter)
result_queue.put(item)
# The consumer pulling from the result_queue will release the slot.
# Resetting here prevents the finally block from double-releasing.
slot_acquired = False
except StopIteration:
pass
except Exception as e:
# Handle errors in `fn` by propagating them to the consumer.
if not stopped.is_set():
result_queue.put(e)
finally:
if slot_acquired:
slots.release()
with remaining_lock:
remaining_workers -= 1
is_last = remaining_workers == 0
# Signal the consumer that all thread workers have exhausted their input.
if is_last and not stopped.is_set():
result_queue.put(_SENTINEL)
worker_threads = [
threading.Thread(target=_worker, name="iter_threaded", daemon=True)
for _ in range(num_workers)
]
for t in worker_threads:
t.start()
try:
while True:
item = result_queue.get()
if item is _SENTINEL:
break
if isinstance(item, Exception):
raise item
# Release one slot at yield time so a worker can run `fn` for the next item.
slots.release()
yield item
finally:
stopped.set()
class _MappingIterator(Iterator[O], Generic[I, O]):
"""Iterator that applies a transform function to each element.
Unlike a generator, local variables in __next__ go out of scope when the method
returns, avoiding holding references to yielded values.
"""
def __init__(self, input_iter: Iterator[I], transform_fn: Callable[[I], O]):
self._input_iter = input_iter
self._transform_fn = transform_fn
def __iter__(self) -> "_MappingIterator[I, O]":
return self
def __next__(self) -> O:
return self._transform_fn(next(self._input_iter))
def _calculate_ref_hits(refs: List[ObjectRef[Any]]) -> Tuple[int, int, int]:
"""Given a list of object references, returns how many are already on the local
node, how many require fetching from another node, and how many have unknown
locations. If `DataContext.get_current().enable_get_object_locations_for_metrics` is
False, this will return `(0, 0, 0)` as getting object locations is disabled."""
current_node_id = ray.get_runtime_context().get_node_id()
ctx = ray.data.DataContext.get_current()
if ctx.enable_get_object_locations_for_metrics:
locs = ray.experimental.get_object_locations(refs)
nodes: List[List[str]] = [loc["node_ids"] for loc in locs.values()]
hits = sum(current_node_id in node_ids for node_ids in nodes)
unknowns = sum(1 for node_ids in nodes if not node_ids)
misses = len(nodes) - hits - unknowns
return hits, misses, unknowns
return 0, 0, 0
def resolve_block_refs(
block_ref_iter: Iterator[ObjectRef[Block]],
stats: Optional[DatasetStats] = None,
) -> Iterator[ResolvedBlock]:
"""Resolve block references via ``ray.get()`` and attach per-block
stage timings.
production_wait is captured manually (no Timer accumulation) to avoid
double-counting with ``prefetch_batches_locally``'s
``iter_get_ref_bundles_s`` timer; data_transfer uses ``_maybe_time``
normally (no overlap with other timers).
Args:
block_ref_iter: An iterator over block object references.
stats: An optional stats object to record block hits, misses, and
cumulative ray.get() time.
Yields:
ResolvedBlock: Each resolved block with its stage timings.
"""
hits = 0
misses = 0
unknowns = 0
while True:
production_wait_start = time.perf_counter() if stats else 0.0
try:
block_ref = next(block_ref_iter)
except StopIteration:
break
production_wait_span = (
TimeSpan(start_s=production_wait_start, end_s=time.perf_counter())
if stats
else None
)
current_hit, current_miss, current_unknown = _calculate_ref_hits([block_ref])
hits += current_hit
misses += current_miss
unknowns += current_unknown
# data_transfer: cross-node transfer via ray.get().
# TODO(amogkam): batch multiple references in one ray.get() call.
with _maybe_time(stats.iter_get_s if stats else None) as data_transfer_span:
block = ray.get(block_ref)
if stats:
assert production_wait_span is not None
assert data_transfer_span is not None
stage_timings = BlockStageTimings(
production_wait=production_wait_span,
data_transfer=data_transfer_span,
)
else:
stage_timings = None
yield ResolvedBlock(block=block, stage_timings=stage_timings)
if stats:
stats.iter_blocks_local = hits
stats.iter_blocks_remote = misses
stats.iter_unknown_location = unknowns
def blocks_to_batches(
block_iter: Iterator[ResolvedBlock],
stats: Optional[DatasetStats] = None,
batch_size: Optional[int] = None,
drop_last: bool = False,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
) -> Iterator[Batch]:
"""Given an iterator over blocks, returns an iterator over batches."""
return _BatchingIterator(
block_iter,
stats=stats,
batch_size=batch_size,
drop_last=drop_last,
shuffle_buffer_min_size=shuffle_buffer_min_size,
shuffle_seed=shuffle_seed,
ensure_copy=ensure_copy,
)
class _BatchingIterator(Iterator[Batch]):
"""Iterator that converts blocks to batches.
Unlike a generator, local variables in __next__ go out of scope when the method
returns, avoiding holding references to yielded values.
"""
def __init__(
self,
block_iter: Iterator[ResolvedBlock],
stats: Optional[DatasetStats] = None,
batch_size: Optional[int] = None,
drop_last: bool = False,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
):
self._block_iter = block_iter
self._stats = stats
self._drop_last = drop_last
self._global_counter = 0
self._done_adding = False
# Accumulates per-block stage timings until a batch is yielded.
self._pending_timings = BatchStageTimings()
if shuffle_buffer_min_size is not None:
self._batcher = ShufflingBatcher(
batch_size=batch_size,
shuffle_buffer_min_size=shuffle_buffer_min_size,
shuffle_seed=shuffle_seed,
)
else:
self._batcher = Batcher(batch_size=batch_size, ensure_copy=ensure_copy)
def __iter__(self) -> "_BatchingIterator":
return self
def __next__(self) -> Batch:
# Try to get a batch from current batcher state
while True:
can_yield = self._batcher.has_batch() or (
self._batcher.has_any() and self._done_adding and not self._drop_last
)
if can_yield:
with _maybe_time(
self._stats.iter_next_batch_s if self._stats else None
) as span:
next_batch = self._batcher.next_batch()
self._pending_timings.batching = span
res = Batch(
metadata=BatchMetadata(
batch_idx=self._global_counter,
num_rows=BlockAccessor.for_block(next_batch).num_rows(),
stage_timings=self._pending_timings,
),
data=next_batch,
)
self._pending_timings = BatchStageTimings()
self._global_counter += 1
return res
elif not self._done_adding:
# If can't yield try adding more blocks
try:
# NOTE: Block ref is released immediately
block_result = next(self._block_iter)
if block_result.stage_timings is not None:
self._pending_timings.accumulate_block_timings(
block_result.stage_timings
)
self._batcher.add(block_result.block)
except StopIteration:
self._batcher.done_adding()
self._done_adding = True
else:
# In case when
# - We've exhausted input AND
# - There's nothing to yield anymore
#
# We stop the iteration
raise StopIteration
def _format_batch(
batch: Batch,
batch_format: Optional[str],
stats: Optional[DatasetStats],
ensure_copy: bool = False,
) -> Batch:
with _maybe_time(stats.iter_format_batch_s if stats else None) as span:
formatted_data = BlockAccessor.for_block(batch.data).to_batch_format(
batch_format
)
if ensure_copy:
formatted_data = _copy_batch(formatted_data)
batch.metadata.stage_timings.format = span
return dataclasses.replace(batch, data=formatted_data)
def _copy_batch(batch: "DataBatch") -> "DataBatch":
"""Return a copy of a batch, making it writable.
``pa.Array.to_numpy()`` returns read-only arrays by default, so when
a caller passes ``ensure_copy=True`` (i.e. ``zero_copy_batch=False``) and the
block is Arrow, the numpy-format batch must be explicitly copied to give the UDF
writable arrays.
"""
import numpy as np
if isinstance(batch, dict):
# Return a dictionary with the same keys (column names) and values (column numpy arrays),
# with the values copied
return {
k: v.copy() if isinstance(v, np.ndarray) else v for k, v in batch.items()
}
elif isinstance(batch, np.ndarray):
return batch.copy()
return batch
def format_batches(
batch_iter: Iterator[Batch],
batch_format: Optional[str],
stats: Optional[DatasetStats] = None,
ensure_copy: bool = False,
) -> Iterator[Batch]:
"""Given an iterator of batches, returns an iterator of formatted batches."""
return _MappingIterator(
batch_iter,
functools.partial(
_format_batch,
batch_format=batch_format,
stats=stats,
ensure_copy=ensure_copy,
),
)
def _collate_batch(
batch: Batch,
collate_fn: Callable[[DataBatch], Any],
stats: Optional[DatasetStats],
) -> CollatedBatch:
with _maybe_time(stats.iter_collate_batch_s if stats else None) as span:
collated_data = collate_fn(batch.data)
batch.metadata.stage_timings.collate = span
return CollatedBatch(metadata=batch.metadata, data=collated_data)
def collate(
batch_iter: Iterator[Batch],
collate_fn: Optional[Callable[[DataBatch], Any]],
stats: Optional[DatasetStats] = None,
) -> Iterator[CollatedBatch]:
"""Returns an iterator with the provided collate_fn applied to batches."""
if not isinstance(batch_iter, Iterator):
batch_iter = iter(batch_iter)
return _MappingIterator(
batch_iter,
functools.partial(_collate_batch, collate_fn=collate_fn, stats=stats),
)
def _finalize_batch(
batch: CollatedBatch,
finalize_fn: Callable[[Any], Any],
stats: Optional[DatasetStats],
) -> CollatedBatch:
with _maybe_time(stats.iter_finalize_batch_s if stats else None) as span:
finalized_data = finalize_fn(batch.data)
batch.metadata.stage_timings.finalize = span
return dataclasses.replace(batch, data=finalized_data)
def finalize_batches(
batch_iter: Iterator[CollatedBatch],
finalize_fn: Callable[[Any], Any],
stats: Optional[DatasetStats] = None,
) -> Iterator[CollatedBatch]:
"""Returns an iterator with finalize_fn applied to batches."""
if not isinstance(batch_iter, Iterator):
batch_iter = iter(batch_iter)
return _MappingIterator(
batch_iter,
functools.partial(_finalize_batch, finalize_fn=finalize_fn, stats=stats),
)
PREFETCHER_ACTOR_NAMESPACE = "ray.dataset"
class WaitBlockPrefetcher(BlockPrefetcher):
"""Block prefetcher using ray.wait."""
def __init__(self):
self._blocks = []
self._stopped = False
self._condition = threading.Condition()
self._thread = threading.Thread(
target=self._run,
name="Prefetcher",
daemon=True,
)
self._thread.start()
def _run(self):
while not self._stopped:
try:
with self._condition:
if len(self._blocks) == 0:
# Park, waiting for notification that prefetching
# should resume
self._condition.wait()
blocks_to_fetch, self._blocks = self._blocks[:], []
if len(blocks_to_fetch) > 0:
ray.wait(
blocks_to_fetch,
num_returns=1,
# NOTE: We deliberately setting timeout to 0 to avoid
# blocking the fetching thread unnecessarily
timeout=0,
fetch_local=True,
)
except Exception:
logger.exception("Error in prefetcher thread.")
logger.debug("Exiting prefetcher's background thread")
def prefetch_blocks(self, blocks: List[ObjectRef[Block]]):
with self._condition:
if self._stopped:
raise RuntimeError("Prefetcher is stopped.")
self._blocks = blocks
self._condition.notify()
def stop(self):
with self._condition:
if self._stopped:
return
self._stopped = True
self._condition.notify()
def __del__(self):
self.stop()
class ActorBlockPrefetcher(BlockPrefetcher):
"""Block prefetcher using a local actor."""
def __init__(self):
self.prefetch_actor = self._get_or_create_actor_prefetcher()
@staticmethod
def _get_or_create_actor_prefetcher() -> "ActorHandle":
node_id = ray.get_runtime_context().get_node_id()
actor_name = f"dataset-block-prefetcher-{node_id}"
return _BlockPretcher.options(
label_selector={ray._raylet.RAY_NODE_ID_KEY: node_id},
name=actor_name,
namespace=PREFETCHER_ACTOR_NAMESPACE,
get_if_exists=True,
).remote()
def prefetch_blocks(self, blocks: List[ObjectRef[Block]]):
self.prefetch_actor.prefetch.remote(*blocks)
@ray.remote(num_cpus=0)
class _BlockPretcher:
"""Helper actor that prefetches blocks asynchronously."""
def prefetch(self, *blocks) -> None:
pass