chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user