562 lines
18 KiB
Python
562 lines
18 KiB
Python
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
|