chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
def get_task_pool_map_operator_cls():
|
||||
from ray.data._internal.execution.operators.task_pool_map_operator import (
|
||||
TaskPoolMapOperator,
|
||||
)
|
||||
|
||||
return TaskPoolMapOperator
|
||||
|
||||
|
||||
def get_actor_pool_map_operator_cls():
|
||||
from ray.data._internal.execution.operators.actor_pool_map_operator import (
|
||||
ActorPoolMapOperator,
|
||||
)
|
||||
|
||||
return ActorPoolMapOperator
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import ray
|
||||
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
BlockEntry,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.block import BlockAccessor
|
||||
from ray.data.context import DataContext
|
||||
|
||||
|
||||
class AggregateNumRows(PhysicalOperator):
|
||||
"""Count number of rows in input bundles.
|
||||
|
||||
This operator aggregates the number of rows in input bundles using the bundles'
|
||||
block metadata. It outputs a single row with the specified column name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_dependencies,
|
||||
data_context: DataContext,
|
||||
column_name: str,
|
||||
):
|
||||
super().__init__(
|
||||
"AggregateNumRows",
|
||||
input_dependencies,
|
||||
data_context,
|
||||
)
|
||||
|
||||
self._column_name = column_name
|
||||
|
||||
self._num_rows = 0
|
||||
self._has_outputted = False
|
||||
self._estimated_num_output_bundles = 1
|
||||
self._estimated_output_num_rows = 1
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return self._inputs_complete and not self._has_outputted
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
assert self._inputs_complete
|
||||
|
||||
builder = DelegatingBlockBuilder()
|
||||
builder.add({self._column_name: self._num_rows})
|
||||
block = builder.build()
|
||||
block_ref = ray.put(block)
|
||||
|
||||
metadata = BlockAccessor.for_block(block).get_metadata()
|
||||
schema = BlockAccessor.for_block(block).schema()
|
||||
bundle = RefBundle(
|
||||
[BlockEntry(block_ref, metadata)], owns_blocks=True, schema=schema
|
||||
)
|
||||
|
||||
self._block_ref_counter.on_block_produced(
|
||||
block_ref, metadata.size_bytes or 0, self.id
|
||||
)
|
||||
self._has_outputted = True
|
||||
return bundle
|
||||
|
||||
def get_stats(self) -> StatsDict:
|
||||
return {}
|
||||
|
||||
def _add_input_inner(self, refs, input_index) -> None:
|
||||
assert refs.num_rows() is not None
|
||||
self._num_rows += refs.num_rows()
|
||||
|
||||
def throttling_disabled(self) -> bool:
|
||||
return True
|
||||
@@ -0,0 +1,268 @@
|
||||
import abc
|
||||
import typing
|
||||
from typing import List, Optional
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from ray.data._internal.execution.bundle_queue import BaseBundleQueue, FIFOBundleQueue
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
AllToAllTransformFn,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
TaskContext,
|
||||
)
|
||||
from ray.data._internal.execution.operators.sub_progress import SubProgressBarMixin
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.context import DataContext
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ray.data._internal.progress.base_progress import BaseProgressBar
|
||||
|
||||
|
||||
class InternalQueueOperatorMixin(PhysicalOperator, abc.ABC):
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _input_queues(self) -> List["BaseBundleQueue"]:
|
||||
"""Return all the internal input buffer queues for this operator."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _output_queues(self) -> List["BaseBundleQueue"]:
|
||||
"""Return all the internal output buffer queues for this operator."""
|
||||
...
|
||||
|
||||
def internal_input_queue_num_blocks(self) -> int:
|
||||
"""Returns Operator's internal input queue size (in blocks)"""
|
||||
return sum(input_buffer.num_blocks() for input_buffer in self._input_queues)
|
||||
|
||||
def internal_input_queue_num_bytes(self) -> int:
|
||||
"""Returns Operator's internal input queue size (in bytes)"""
|
||||
return sum(
|
||||
input_buffer.estimate_size_bytes() for input_buffer in self._input_queues
|
||||
)
|
||||
|
||||
def internal_output_queue_num_blocks(self) -> int:
|
||||
"""Returns Operator's internal output queue size (in blocks)"""
|
||||
return sum(output_buffer.num_blocks() for output_buffer in self._output_queues)
|
||||
|
||||
def internal_output_queue_num_bytes(self) -> int:
|
||||
"""Returns Operator's internal output queue size (in bytes)"""
|
||||
return sum(
|
||||
output_buffer.estimate_size_bytes() for output_buffer in self._output_queues
|
||||
)
|
||||
|
||||
def clear_internal_input_queue(self) -> None:
|
||||
"""Clear internal input queue(s)."""
|
||||
for input_buffer in self._input_queues:
|
||||
input_buffer.clear()
|
||||
|
||||
def clear_internal_output_queue(self) -> None:
|
||||
"""Clear internal output queue(s)."""
|
||||
for output_buffer in self._output_queues:
|
||||
output_buffer.clear()
|
||||
|
||||
def mark_execution_finished(self) -> None:
|
||||
"""Mark execution as finished and clear internal queues.
|
||||
|
||||
This default implementation calls the parent's mark_execution_finished()
|
||||
and then clears internal input and output queues.
|
||||
"""
|
||||
super().mark_execution_finished()
|
||||
self.clear_internal_input_queue()
|
||||
self.clear_internal_output_queue()
|
||||
|
||||
|
||||
class OneToOneOperator(PhysicalOperator):
|
||||
"""An operator that has one input and one output dependency.
|
||||
|
||||
This operator serves as the base for map, filter, limit, etc.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
input_op: PhysicalOperator,
|
||||
data_context: DataContext,
|
||||
target_max_block_size_override: Optional[int] = None,
|
||||
):
|
||||
"""Create a OneToOneOperator.
|
||||
|
||||
Args:
|
||||
name: The name of this operator.
|
||||
input_op: Operator generating input data for this op.
|
||||
data_context: The :class:`DataContext` to use for this operator.
|
||||
target_max_block_size_override: The target maximum number of bytes to
|
||||
include in an output block.
|
||||
"""
|
||||
super().__init__(name, [input_op], data_context, target_max_block_size_override)
|
||||
|
||||
@property
|
||||
def input_dependency(self) -> PhysicalOperator:
|
||||
return self.input_dependencies[0]
|
||||
|
||||
|
||||
class AllToAllOperator(
|
||||
InternalQueueOperatorMixin, SubProgressBarMixin, PhysicalOperator
|
||||
):
|
||||
"""A blocking operator that executes once its inputs are complete.
|
||||
|
||||
This operator implements distributed sort / shuffle operations, etc.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bulk_fn: AllToAllTransformFn,
|
||||
input_op: PhysicalOperator,
|
||||
data_context: DataContext,
|
||||
target_max_block_size_override: Optional[int] = None,
|
||||
num_outputs: Optional[int] = None,
|
||||
sub_progress_bar_names: Optional[List[str]] = None,
|
||||
name: str = "AllToAll",
|
||||
):
|
||||
"""Create an AllToAllOperator.
|
||||
Args:
|
||||
bulk_fn: The blocking transformation function to run. The inputs are the
|
||||
list of input ref bundles, and the outputs are the output ref bundles
|
||||
and a stats dict.
|
||||
input_op: Operator generating input data for this op.
|
||||
data_context: The DataContext instance containing configuration settings.
|
||||
target_max_block_size_override: The target maximum number of bytes to
|
||||
include in an output block.
|
||||
num_outputs: The number of expected output bundles for progress bar.
|
||||
sub_progress_bar_names: The names of internal sub progress bars.
|
||||
name: The name of this operator.
|
||||
"""
|
||||
self._bulk_fn = bulk_fn
|
||||
self._next_task_index = 0
|
||||
self._num_outputs = num_outputs
|
||||
self._output_rows = 0
|
||||
self._sub_progress_bar_names = sub_progress_bar_names
|
||||
self._sub_progress_bar_dict = None
|
||||
self._input_buffer: FIFOBundleQueue = FIFOBundleQueue()
|
||||
self._output_buffer: FIFOBundleQueue = FIFOBundleQueue()
|
||||
self._stats: StatsDict = {}
|
||||
super().__init__(name, [input_op], data_context, target_max_block_size_override)
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._input_buffer]
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._output_buffer]
|
||||
|
||||
def num_outputs_total(self) -> Optional[int]:
|
||||
return (
|
||||
self._num_outputs
|
||||
if self._num_outputs
|
||||
else self.input_dependencies[0].num_outputs_total()
|
||||
)
|
||||
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
return (
|
||||
self._output_rows
|
||||
if self._output_rows
|
||||
else self.input_dependencies[0].num_output_rows_total()
|
||||
)
|
||||
|
||||
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
||||
assert not self.has_completed()
|
||||
assert input_index == 0, input_index
|
||||
self._input_buffer.add(refs)
|
||||
self._metrics.on_input_queued(refs, input_index=0)
|
||||
|
||||
def all_inputs_done(self) -> None:
|
||||
ctx = TaskContext(
|
||||
task_idx=self._next_task_index,
|
||||
op_name=self.name,
|
||||
sub_progress_bar_dict=self._sub_progress_bar_dict,
|
||||
target_max_block_size_override=self.target_max_block_size_override,
|
||||
)
|
||||
# NOTE: We don't account object store memory use from intermediate `bulk_fn`
|
||||
# outputs (e.g., map outputs for map-reduce).
|
||||
|
||||
input_bundles = self._input_buffer.to_list()
|
||||
output_buffer, self._stats = self._bulk_fn(input_bundles, ctx)
|
||||
self._output_buffer = FIFOBundleQueue(output_buffer)
|
||||
|
||||
for bundle in output_buffer:
|
||||
for entry in bundle.blocks:
|
||||
self._block_ref_counter.on_block_produced(
|
||||
entry.ref, entry.metadata.size_bytes or 0, self.id
|
||||
)
|
||||
|
||||
while self._input_buffer.has_next():
|
||||
refs = self._input_buffer.get_next()
|
||||
self._metrics.on_input_dequeued(refs, input_index=0)
|
||||
|
||||
for ref in self._output_buffer:
|
||||
self._metrics.on_output_queued(ref)
|
||||
|
||||
self._next_task_index += 1
|
||||
|
||||
super().all_inputs_done()
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return len(self._output_buffer) > 0
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
bundle = self._output_buffer.get_next()
|
||||
self._metrics.on_output_dequeued(bundle)
|
||||
self._output_rows += bundle.num_rows()
|
||||
return bundle
|
||||
|
||||
def get_stats(self) -> StatsDict:
|
||||
return self._stats
|
||||
|
||||
def get_transformation_fn(self) -> AllToAllTransformFn:
|
||||
return self._bulk_fn
|
||||
|
||||
def progress_str(self) -> str:
|
||||
return f"{self.num_output_rows_total() or 0} rows output"
|
||||
|
||||
def get_sub_progress_bar_names(self) -> Optional[List[str]]:
|
||||
return self._sub_progress_bar_names
|
||||
|
||||
def set_sub_progress_bar(self, name: str, pg: "BaseProgressBar"):
|
||||
if self._sub_progress_bar_dict is None:
|
||||
self._sub_progress_bar_dict = {}
|
||||
self._sub_progress_bar_dict[name] = pg
|
||||
|
||||
def supports_fusion(self):
|
||||
return True
|
||||
|
||||
def throttling_disabled(self) -> bool:
|
||||
# Disable resource allocation and throttling for the operator
|
||||
return True
|
||||
|
||||
|
||||
class NAryOperator(PhysicalOperator):
|
||||
"""An operator that has multiple input dependencies and one output.
|
||||
|
||||
This operator serves as the base for union, zip, etc.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_context: DataContext,
|
||||
*input_ops: PhysicalOperator,
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
"""Create a NAryOperator.
|
||||
|
||||
Args:
|
||||
data_context: The DataContext instance containing configuration settings.
|
||||
*input_ops: Operators generating input data for this op.
|
||||
name: Optional override for the operator display name.
|
||||
"""
|
||||
if name is None:
|
||||
input_names = ", ".join([op._name for op in input_ops])
|
||||
name = f"{self.__class__.__name__}({input_names})"
|
||||
super().__init__(
|
||||
name,
|
||||
list(input_ops),
|
||||
data_context,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from ray.data._internal.execution.interfaces import PhysicalOperator
|
||||
from ray.data._internal.execution.operators.hash_shuffle import (
|
||||
BlockTransformer,
|
||||
HashShufflingOperatorBase,
|
||||
ShuffleAggregation,
|
||||
)
|
||||
from ray.data._internal.util import GiB, MiB
|
||||
from ray.data.aggregate import AggregateFn
|
||||
from ray.data.block import Block, BlockAccessor
|
||||
from ray.data.context import DataContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReducingAggregation(ShuffleAggregation):
|
||||
"""Stateless aggregation that reduces blocks using aggregation functions.
|
||||
|
||||
This implementation performs incremental reduction during compaction,
|
||||
combining multiple partially-aggregated blocks into one. The final
|
||||
aggregation is performed during finalization.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_columns: Tuple[str, ...],
|
||||
aggregation_fns: Tuple[AggregateFn, ...],
|
||||
):
|
||||
self._sort_key: "SortKey" = self._get_sort_key(key_columns)
|
||||
self._aggregation_fns: Tuple[AggregateFn, ...] = aggregation_fns
|
||||
|
||||
@classmethod
|
||||
def is_compacting(cls):
|
||||
return True
|
||||
|
||||
def compact(self, partition_shards: List[Block]) -> Block:
|
||||
assert len(partition_shards) > 0, "Provided sequence must be non-empty"
|
||||
|
||||
return self._combine(partition_shards, finalize=False)
|
||||
|
||||
def finalize(self, partition_shards_map: Dict[int, List[Block]]) -> Iterator[Block]:
|
||||
assert (
|
||||
len(partition_shards_map) == 1
|
||||
), f"Single input-sequence is expected (got {len(partition_shards_map)})"
|
||||
|
||||
blocks = partition_shards_map[0]
|
||||
|
||||
if not blocks:
|
||||
return
|
||||
|
||||
yield self._combine(blocks, finalize=True)
|
||||
|
||||
def _combine(self, blocks: List[Block], *, finalize: bool) -> Block:
|
||||
"""Internal method to combine blocks with optional finalization."""
|
||||
assert len(blocks) > 0
|
||||
|
||||
block_accessor = BlockAccessor.for_block(blocks[0])
|
||||
combined_block, _ = block_accessor._combine_aggregated_blocks(
|
||||
blocks,
|
||||
sort_key=self._sort_key,
|
||||
aggs=self._aggregation_fns,
|
||||
finalize=finalize,
|
||||
)
|
||||
|
||||
return combined_block
|
||||
|
||||
@staticmethod
|
||||
def _get_sort_key(key_columns: Tuple[str, ...]) -> "SortKey":
|
||||
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
|
||||
|
||||
return SortKey(key=list(key_columns), descending=False)
|
||||
|
||||
|
||||
class HashAggregateOperator(HashShufflingOperatorBase):
|
||||
|
||||
_DEFAULT_MIN_NUM_SHARDS_COMPACTION_THRESHOLD = 100
|
||||
_DEFAULT_MAX_NUM_SHARDS_COMPACTION_THRESHOLD = 2000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_context: DataContext,
|
||||
input_op: PhysicalOperator,
|
||||
key_columns: Tuple[str],
|
||||
aggregation_fns: Tuple[AggregateFn],
|
||||
*,
|
||||
num_partitions: Optional[int] = None,
|
||||
aggregator_ray_remote_args_override: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
# Use new stateless ReducingAggregation factory
|
||||
def _create_reducing_aggregation() -> ReducingAggregation:
|
||||
return ReducingAggregation(
|
||||
key_columns=key_columns,
|
||||
aggregation_fns=aggregation_fns,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
name_factory=(
|
||||
lambda num_partitions: f"HashAggregate(key_columns={key_columns}, "
|
||||
f"num_partitions={num_partitions})"
|
||||
),
|
||||
input_ops=[input_op],
|
||||
data_context=data_context,
|
||||
key_columns=[key_columns],
|
||||
num_input_seqs=1,
|
||||
num_partitions=(
|
||||
# NOTE: In case of global aggregations (ie with no key columns specified),
|
||||
# we override number of partitions to 1, since the whole dataset
|
||||
# will be reduced to just a single row
|
||||
num_partitions
|
||||
if len(key_columns) > 0
|
||||
else 1
|
||||
),
|
||||
partition_aggregation_factory=_create_reducing_aggregation,
|
||||
input_block_transformer=_create_aggregating_transformer(
|
||||
key_columns, aggregation_fns
|
||||
),
|
||||
aggregator_ray_remote_args_override=aggregator_ray_remote_args_override,
|
||||
shuffle_progress_bar_name="Shuffle",
|
||||
finalize_progress_bar_name="Aggregation",
|
||||
)
|
||||
|
||||
def _get_operator_num_cpus_override(self) -> float:
|
||||
return self.data_context.hash_aggregate_operator_actor_num_cpus_override
|
||||
|
||||
@classmethod
|
||||
def _estimate_aggregator_memory_allocation(
|
||||
cls,
|
||||
*,
|
||||
num_aggregators: int,
|
||||
num_partitions: int,
|
||||
estimated_dataset_bytes: int,
|
||||
) -> int:
|
||||
partition_byte_size_estimate = math.ceil(
|
||||
estimated_dataset_bytes / num_partitions
|
||||
)
|
||||
|
||||
# Estimate of object store memory required to accommodate all partitions
|
||||
# handled by a single aggregator
|
||||
aggregator_shuffle_object_store_memory_required: int = math.ceil(
|
||||
estimated_dataset_bytes / num_aggregators
|
||||
)
|
||||
# Estimate of memory required to accommodate single partition as an output
|
||||
# (inside Object Store)
|
||||
output_object_store_memory_required: int = partition_byte_size_estimate
|
||||
|
||||
aggregator_total_memory_required: int = (
|
||||
# Inputs (object store)
|
||||
aggregator_shuffle_object_store_memory_required
|
||||
+
|
||||
# Output (object store)
|
||||
output_object_store_memory_required
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Estimated memory requirement for aggregating aggregator "
|
||||
f"(partitions={num_partitions}, "
|
||||
f"aggregators={num_aggregators}, "
|
||||
f"dataset (estimate)={estimated_dataset_bytes / GiB:.1f}GiB): "
|
||||
f"shuffle={aggregator_shuffle_object_store_memory_required / MiB:.1f}MiB, "
|
||||
f"output={output_object_store_memory_required / MiB:.1f}MiB, "
|
||||
f"total={aggregator_total_memory_required / MiB:.1f}MiB, "
|
||||
)
|
||||
|
||||
return aggregator_total_memory_required
|
||||
|
||||
@classmethod
|
||||
def _get_min_max_partition_shards_compaction_thresholds(
|
||||
cls,
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
return (
|
||||
cls._DEFAULT_MIN_NUM_SHARDS_COMPACTION_THRESHOLD,
|
||||
cls._DEFAULT_MAX_NUM_SHARDS_COMPACTION_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
def _create_aggregating_transformer(
|
||||
key_columns: Tuple[str], aggregation_fns: Tuple[AggregateFn]
|
||||
) -> BlockTransformer:
|
||||
"""Method creates input block transformer performing partial aggregation of
|
||||
the block applied prior to block being shuffled (to reduce amount of bytes shuffled)"""
|
||||
|
||||
sort_key = ReducingAggregation._get_sort_key(key_columns)
|
||||
|
||||
def _aggregate(block: Block) -> Block:
|
||||
from ray.data._internal.planner.exchange.aggregate_task_spec import (
|
||||
SortAggregateTaskSpec,
|
||||
)
|
||||
|
||||
# TODO unify blocks schemas, to avoid validating every block
|
||||
# Validate block's schema compatible with aggregations
|
||||
for agg_fn in aggregation_fns:
|
||||
agg_fn._validate(BlockAccessor.for_block(block).schema())
|
||||
|
||||
# Project block to only carry columns used in aggregation
|
||||
pruned_block = SortAggregateTaskSpec._prune_unused_columns(
|
||||
block,
|
||||
sort_key,
|
||||
aggregation_fns,
|
||||
)
|
||||
|
||||
# NOTE: If columns to aggregate on have been provided,
|
||||
# sort the block on these before aggregation
|
||||
if sort_key.get_columns():
|
||||
target_block = BlockAccessor.for_block(pruned_block).sort(sort_key)
|
||||
else:
|
||||
target_block = pruned_block
|
||||
|
||||
return BlockAccessor.for_block(target_block)._aggregate(
|
||||
sort_key, aggregation_fns
|
||||
)
|
||||
|
||||
return _aggregate
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.arrow_ops.transform_pyarrow import hash_partition
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_tasks import (
|
||||
PartitionFn,
|
||||
ReduceFn,
|
||||
)
|
||||
|
||||
# Isolate shuffle map workers into a dedicated worker pool so that
|
||||
# ReadParquet/Project tasks don't run on the same workers. Without this,
|
||||
# shared memory pages from object store accesses (mmap'd during
|
||||
# combine_chunks) accumulate across task types and inflate worker RSS.
|
||||
_SHUFFLE_MAP_RUNTIME_ENV = {"env_vars": {"RAY_DATA_SHUFFLE_MAP_WORKER": "1"}}
|
||||
|
||||
|
||||
def _make_hash_partition_fn(key_columns: List[str], num_partitions: int) -> PartitionFn:
|
||||
"""Return a partition function that hash-partitions by key_columns."""
|
||||
|
||||
def _partition(block: pa.Table) -> Dict[int, pa.Table]:
|
||||
return hash_partition(
|
||||
block, hash_cols=key_columns, num_partitions=num_partitions
|
||||
)
|
||||
|
||||
return _partition
|
||||
|
||||
|
||||
def _concat_reduce(
|
||||
partition_id: int, tables_by_input: List[List[pa.Table]]
|
||||
) -> Iterable[pa.Table]:
|
||||
"""Concatenate all shards of a (single-input) partition into one block."""
|
||||
tables = tables_by_input[0]
|
||||
if not tables:
|
||||
return
|
||||
yield pa.concat_tables(tables) if len(tables) > 1 else tables[0]
|
||||
|
||||
|
||||
def _sort_reduce(key_columns: List[str]) -> ReduceFn:
|
||||
"""Return a reduce function that concatenates then sorts by key_columns.
|
||||
|
||||
Requires blocking mode because sorting needs all shards before emitting.
|
||||
"""
|
||||
|
||||
def _reduce(
|
||||
partition_id: int, tables_by_input: List[List[pa.Table]]
|
||||
) -> Iterable[pa.Table]:
|
||||
tables = tables_by_input[0]
|
||||
if not tables:
|
||||
return
|
||||
combined = pa.concat_tables(tables) if len(tables) > 1 else tables[0]
|
||||
yield combined.sort_by([(k, "ascending") for k in key_columns])
|
||||
|
||||
return _reduce
|
||||
@@ -0,0 +1,103 @@
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
|
||||
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
ExecutionOptions,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.context import DataContext
|
||||
|
||||
|
||||
class InputDataBuffer(PhysicalOperator):
|
||||
"""Defines the input data for the operator DAG.
|
||||
|
||||
For example, this may hold cached blocks from a previous Dataset execution, or
|
||||
the arguments for read tasks.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_context: DataContext,
|
||||
input_data: Optional[List[RefBundle]] = None,
|
||||
input_data_factory: Optional[Callable[[int], List[RefBundle]]] = None,
|
||||
):
|
||||
"""Create an InputDataBuffer.
|
||||
|
||||
Args:
|
||||
data_context: :class:`~ray.data.context.DataContext`
|
||||
object to use injestion.
|
||||
input_data: The list of bundles to output from this operator.
|
||||
input_data_factory: The factory to get input data, if input_data is None.
|
||||
"""
|
||||
super().__init__("Input", [], data_context)
|
||||
if input_data is not None:
|
||||
assert input_data_factory is None
|
||||
# Copy the input data to avoid mutating the original list.
|
||||
self._input_data = input_data[:]
|
||||
self._is_input_initialized = True
|
||||
self._initialize_metadata()
|
||||
else:
|
||||
# Initialize input lazily when execution is started.
|
||||
assert input_data_factory is not None
|
||||
self._input_data_factory = input_data_factory
|
||||
self._is_input_initialized = False
|
||||
self._input_data_index = 0
|
||||
self.mark_execution_finished()
|
||||
|
||||
def start(
|
||||
self,
|
||||
options: ExecutionOptions,
|
||||
block_ref_counter: "BlockRefCounter",
|
||||
) -> None:
|
||||
if not self._is_input_initialized:
|
||||
self._input_data = self._input_data_factory(
|
||||
self.target_max_block_size_override
|
||||
or self.data_context.target_max_block_size
|
||||
)
|
||||
self._is_input_initialized = True
|
||||
self._initialize_metadata()
|
||||
# InputDataBuffer does not take inputs from other operators,
|
||||
# so we record input metrics here
|
||||
for bundle in self._input_data:
|
||||
self._metrics.on_input_received(bundle)
|
||||
super().start(options, block_ref_counter)
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return self._input_data_index < len(self._input_data)
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
# We can't pop the input data. If we do, Ray might garbage collect the block
|
||||
# references, and Ray won't be able to reconstruct downstream objects.
|
||||
bundle = self._input_data[self._input_data_index]
|
||||
self._input_data_index += 1
|
||||
return bundle
|
||||
|
||||
def get_stats(self) -> StatsDict:
|
||||
return {}
|
||||
|
||||
def _add_input_inner(self, refs, input_index) -> None:
|
||||
raise ValueError("Inputs are not allowed for this operator.")
|
||||
|
||||
def _initialize_metadata(self):
|
||||
assert self._input_data is not None and self._is_input_initialized
|
||||
self._estimated_num_output_bundles = len(self._input_data)
|
||||
|
||||
block_metadata = []
|
||||
total_rows = 0
|
||||
for bundle in self._input_data:
|
||||
block_metadata.extend(bundle.metadata)
|
||||
bundle_num_rows = bundle.num_rows()
|
||||
if total_rows is not None and bundle_num_rows is not None:
|
||||
total_rows += bundle_num_rows
|
||||
else:
|
||||
# total row is unknown
|
||||
total_rows = None
|
||||
if total_rows:
|
||||
self._estimated_num_output_rows = total_rows
|
||||
self._stats = {
|
||||
"input": block_metadata,
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Set, Tuple, Type
|
||||
|
||||
from ray.data._internal.arrow_block import ArrowBlockAccessor
|
||||
from ray.data._internal.arrow_ops.transform_pyarrow import (
|
||||
MIN_PYARROW_VERSION_RUN_END_ENCODED_TYPES,
|
||||
MIN_PYARROW_VERSION_VIEW_TYPES,
|
||||
)
|
||||
from ray.data._internal.execution.interfaces import PhysicalOperator
|
||||
from ray.data._internal.execution.operators.hash_shuffle import (
|
||||
HashShufflingOperatorBase,
|
||||
ShuffleAggregation,
|
||||
_combine,
|
||||
)
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_tasks import (
|
||||
ReduceFn,
|
||||
)
|
||||
from ray.data._internal.logical.operators import JoinType
|
||||
from ray.data._internal.util import GiB, MiB
|
||||
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
|
||||
from ray.data._internal.utils.transform_pyarrow import _is_pa_extension_type
|
||||
from ray.data.block import Block
|
||||
from ray.data.context import DataContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow as pa
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DatasetPreprocessingResult:
|
||||
"""Result of join preprocessing containing split tables.
|
||||
|
||||
Separates tables into supported (directly joinable) and unsupported
|
||||
(requires indexing) column projections.
|
||||
"""
|
||||
|
||||
supported_projection: "pa.Table"
|
||||
unsupported_projection: "pa.Table"
|
||||
|
||||
|
||||
_JOIN_TYPE_TO_ARROW_JOIN_VERB_MAP = {
|
||||
JoinType.INNER: "inner",
|
||||
JoinType.LEFT_OUTER: "left outer",
|
||||
JoinType.RIGHT_OUTER: "right outer",
|
||||
JoinType.FULL_OUTER: "full outer",
|
||||
JoinType.LEFT_SEMI: "left semi",
|
||||
JoinType.RIGHT_SEMI: "right semi",
|
||||
JoinType.LEFT_ANTI: "left anti",
|
||||
JoinType.RIGHT_ANTI: "right anti",
|
||||
}
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JoiningAggregation(ShuffleAggregation):
|
||||
"""Stateless aggregation for distributed joining of 2 sequences.
|
||||
|
||||
This implementation performs hash-based distributed joining by:
|
||||
- Accumulating identical keys from both sequences into the same partition
|
||||
- Performing join on individual partitions independently
|
||||
|
||||
For actual joining, Pyarrow native joining functionality is utilised.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
join_type: JoinType,
|
||||
left_key_col_names: Tuple[str, ...],
|
||||
right_key_col_names: Tuple[str, ...],
|
||||
left_columns_suffix: Optional[str] = None,
|
||||
right_columns_suffix: Optional[str] = None,
|
||||
data_context: DataContext,
|
||||
):
|
||||
assert (
|
||||
len(left_key_col_names) > 0
|
||||
), "At least 1 column to join on has to be provided"
|
||||
assert len(right_key_col_names) == len(
|
||||
left_key_col_names
|
||||
), "Number of columns for both left and right join operands has to match"
|
||||
|
||||
assert join_type in _JOIN_TYPE_TO_ARROW_JOIN_VERB_MAP, (
|
||||
f"Join type is not currently supported (got: {join_type}; " # noqa: C416
|
||||
f"supported: {[jt for jt in JoinType]})" # noqa: C416
|
||||
)
|
||||
|
||||
self._left_key_col_names: Tuple[str, ...] = left_key_col_names
|
||||
self._right_key_col_names: Tuple[str, ...] = right_key_col_names
|
||||
self._join_type: JoinType = join_type
|
||||
|
||||
self._left_columns_suffix: Optional[str] = left_columns_suffix
|
||||
self._right_columns_suffix: Optional[str] = right_columns_suffix
|
||||
|
||||
def finalize(self, partition_shards_map: Dict[int, List[Block]]) -> Iterator[Block]:
|
||||
"""Performs join on blocks from left (seq 0) and right (seq 1) sequences."""
|
||||
|
||||
assert (
|
||||
len(partition_shards_map) == 2
|
||||
), f"Two input-sequences are expected (got {len(partition_shards_map)})"
|
||||
|
||||
left_partition_shards = partition_shards_map[0]
|
||||
right_partition_shards = partition_shards_map[1]
|
||||
|
||||
left_table = _combine(left_partition_shards)
|
||||
right_table = _combine(right_partition_shards)
|
||||
|
||||
yield join_tables(
|
||||
left_table,
|
||||
right_table,
|
||||
join_type=self._join_type,
|
||||
left_key_col_names=self._left_key_col_names,
|
||||
right_key_col_names=self._right_key_col_names,
|
||||
left_columns_suffix=self._left_columns_suffix,
|
||||
right_columns_suffix=self._right_columns_suffix,
|
||||
)
|
||||
|
||||
|
||||
def _make_join_reduce_fn(
|
||||
*,
|
||||
join_type: JoinType,
|
||||
left_key_col_names: Tuple[str, ...],
|
||||
right_key_col_names: Tuple[str, ...],
|
||||
left_columns_suffix: Optional[str] = None,
|
||||
right_columns_suffix: Optional[str] = None,
|
||||
left_schema: Optional[Any] = None,
|
||||
right_schema: Optional[Any] = None,
|
||||
) -> ReduceFn:
|
||||
"""Build a V2-shuffle reduce fn that joins two co-partitioned inputs."""
|
||||
import pyarrow as pa
|
||||
|
||||
def _side_table(tables: List[Block], schema: Optional[Any]) -> Optional["pa.Table"]:
|
||||
if tables:
|
||||
return _combine(tables)
|
||||
if isinstance(schema, pa.Schema):
|
||||
return schema.empty_table()
|
||||
return None
|
||||
|
||||
def _reduce(
|
||||
partition_id: int, tables_by_input: List[List[Block]]
|
||||
) -> Iterator[Block]:
|
||||
assert (
|
||||
len(tables_by_input) == 2
|
||||
), f"Join reduce expects two inputs (got {len(tables_by_input)})"
|
||||
left_table = _side_table(tables_by_input[0], left_schema)
|
||||
right_table = _side_table(tables_by_input[1], right_schema)
|
||||
if left_table is None or right_table is None:
|
||||
# TODO(you-cheng): A whole input side is empty AND its schema can't be inferred
|
||||
# (0 blocks + un-inferable schema, e.g. a map_batches side), so
|
||||
# _side_table returns None and we skip the partition. This silently
|
||||
# drops the preserved side's rows for preserving joins, left_outer/
|
||||
# full_outer and left_anti/right_anti.
|
||||
return
|
||||
yield join_tables(
|
||||
left_table,
|
||||
right_table,
|
||||
join_type=join_type,
|
||||
left_key_col_names=left_key_col_names,
|
||||
right_key_col_names=right_key_col_names,
|
||||
left_columns_suffix=left_columns_suffix,
|
||||
right_columns_suffix=right_columns_suffix,
|
||||
)
|
||||
|
||||
return _reduce
|
||||
|
||||
|
||||
def join_tables(
|
||||
left_table: "pa.Table",
|
||||
right_table: "pa.Table",
|
||||
*,
|
||||
join_type: JoinType,
|
||||
left_key_col_names: Tuple[str, ...],
|
||||
right_key_col_names: Tuple[str, ...],
|
||||
left_columns_suffix: Optional[str] = None,
|
||||
right_columns_suffix: Optional[str] = None,
|
||||
) -> "pa.Table":
|
||||
"""Apply preprocess -> ``pa.Table.join`` -> postprocess to two input tables.
|
||||
|
||||
Shared between the physical executor (``JoiningAggregation.finalize``)
|
||||
and plan-time schema inference (``Join.infer_schema``), which calls
|
||||
this with empty tables built from the input schemas. Plan-time and
|
||||
runtime schemas therefore agree by construction.
|
||||
"""
|
||||
left_on = list(left_key_col_names)
|
||||
right_on = list(right_key_col_names)
|
||||
|
||||
# Eagerly validate suffix conflicts so callers get a clear error instead
|
||||
# of the opaque PyArrow schema-merge error ('Field X exists 2 times').
|
||||
# Skip for semi/anti joins: only one side's columns appear in the result,
|
||||
# so overlapping non-key names between left and right are harmless.
|
||||
if join_type not in (
|
||||
JoinType.LEFT_SEMI,
|
||||
JoinType.LEFT_ANTI,
|
||||
JoinType.RIGHT_SEMI,
|
||||
JoinType.RIGHT_ANTI,
|
||||
):
|
||||
left_cols = set(left_table.schema.names)
|
||||
# PyArrow drops right key columns from output (coalescing them into
|
||||
# the left keys), so only right non-key columns can collide with
|
||||
# left columns. Subtracting only right_on (not left_on) correctly
|
||||
# handles asymmetric key names (left_on != right_on).
|
||||
right_output_cols = set(right_table.schema.names) - set(right_on)
|
||||
collisions = left_cols & right_output_cols
|
||||
if left_columns_suffix is None and right_columns_suffix is None and collisions:
|
||||
raise ValueError(
|
||||
"Left and right columns suffixes cannot be both None "
|
||||
f"(overlapping columns: {sorted(collisions)})"
|
||||
)
|
||||
|
||||
# Preprocess: split unsupported columns and add index columns if needed
|
||||
preprocess_result_l, preprocess_result_r = _preprocess(
|
||||
left_table, right_table, left_on, right_on, join_type
|
||||
)
|
||||
|
||||
# Perform the join on supported columns
|
||||
arrow_join_type = _JOIN_TYPE_TO_ARROW_JOIN_VERB_MAP[join_type]
|
||||
|
||||
supported = preprocess_result_l.supported_projection.join(
|
||||
preprocess_result_r.supported_projection,
|
||||
join_type=arrow_join_type,
|
||||
keys=left_on,
|
||||
right_keys=right_on,
|
||||
left_suffix=left_columns_suffix,
|
||||
right_suffix=right_columns_suffix,
|
||||
)
|
||||
|
||||
# Add back unsupported columns
|
||||
return _postprocess(
|
||||
supported,
|
||||
preprocess_result_l.unsupported_projection,
|
||||
preprocess_result_r.unsupported_projection,
|
||||
)
|
||||
|
||||
|
||||
def _preprocess(
|
||||
left_table: "pa.Table",
|
||||
right_table: "pa.Table",
|
||||
left_on: List[str],
|
||||
right_on: List[str],
|
||||
join_type: JoinType,
|
||||
) -> Tuple[_DatasetPreprocessingResult, _DatasetPreprocessingResult]:
|
||||
"""Split inputs into supported/unsupported columns and add indices."""
|
||||
supported_l, unsupported_l = _split_unsupported_columns(left_table)
|
||||
supported_r, unsupported_r = _split_unsupported_columns(right_table)
|
||||
|
||||
# Handle joins on unsupported columns
|
||||
conflicting_columns: Set[str] = set(unsupported_l.column_names) & set(left_on)
|
||||
if conflicting_columns:
|
||||
raise ValueError(
|
||||
f"Cannot join on columns with unjoinable types. "
|
||||
f"Left join key columns {conflicting_columns} have unjoinable types "
|
||||
f"(map, union, list, struct, etc.) which cannot be used for join operations."
|
||||
)
|
||||
|
||||
conflicting_columns: Set[str] = set(unsupported_r.column_names) & set(right_on)
|
||||
if conflicting_columns:
|
||||
raise ValueError(
|
||||
f"Cannot join on columns with unjoinable types. "
|
||||
f"Right join key columns {conflicting_columns} have unjoinable types "
|
||||
f"(map, union, list, struct, etc.) which cannot be used for join operations."
|
||||
)
|
||||
|
||||
# Index if we have unsupported columns
|
||||
should_index_l = _should_index_side("left", supported_l, unsupported_l, join_type)
|
||||
should_index_r = _should_index_side("right", supported_r, unsupported_r, join_type)
|
||||
|
||||
# Add index columns for back-referencing if we have unsupported columns
|
||||
if should_index_l:
|
||||
supported_l = _append_index_column(
|
||||
table=supported_l, col_name=_index_name("left")
|
||||
)
|
||||
if should_index_r:
|
||||
supported_r = _append_index_column(
|
||||
table=supported_r, col_name=_index_name("right")
|
||||
)
|
||||
|
||||
left = _DatasetPreprocessingResult(
|
||||
supported_projection=supported_l,
|
||||
unsupported_projection=unsupported_l,
|
||||
)
|
||||
right = _DatasetPreprocessingResult(
|
||||
supported_projection=supported_r,
|
||||
unsupported_projection=unsupported_r,
|
||||
)
|
||||
return left, right
|
||||
|
||||
|
||||
def _postprocess(
|
||||
supported: "pa.Table",
|
||||
unsupported_l: "pa.Table",
|
||||
unsupported_r: "pa.Table",
|
||||
) -> "pa.Table":
|
||||
"""Re-attach unsupported columns to the joined table via the index column."""
|
||||
should_index_l = _index_name("left") in supported.schema.names
|
||||
should_index_r = _index_name("right") in supported.schema.names
|
||||
|
||||
# Add back unsupported columns (join type logic is in should_index_* variables)
|
||||
if should_index_l:
|
||||
supported = _add_back_unsupported_columns(
|
||||
joined_table=supported,
|
||||
unsupported_table=unsupported_l,
|
||||
index_col_name=_index_name("left"),
|
||||
)
|
||||
|
||||
if should_index_r:
|
||||
supported = _add_back_unsupported_columns(
|
||||
joined_table=supported,
|
||||
unsupported_table=unsupported_r,
|
||||
index_col_name=_index_name("right"),
|
||||
)
|
||||
|
||||
return supported
|
||||
|
||||
|
||||
def _index_name(suffix: str) -> str:
|
||||
return f"__rd_index_level_{suffix}__"
|
||||
|
||||
|
||||
def _should_index_side(
|
||||
side: str,
|
||||
supported_table: "pa.Table",
|
||||
unsupported_table: "pa.Table",
|
||||
join_type: JoinType,
|
||||
) -> bool:
|
||||
"""
|
||||
Determine whether to create an index column for a given side of the join.
|
||||
|
||||
A column is "supported" if it is "joinable", and "unsupported" otherwise.
|
||||
A supported_table is a table with only "supported" columns. Index columns are
|
||||
needed when we have both supported and unsupported columns in a table, and
|
||||
that table's columns will appear in the final result.
|
||||
|
||||
Args:
|
||||
side: "left" or "right" to indicate which side of the join
|
||||
supported_table: Table containing ONLY joinable columns
|
||||
unsupported_table: Table containing ONLY unjoinable columns
|
||||
join_type: The join type, used to decide whether this side appears in
|
||||
the result (semi/anti joins drop one side).
|
||||
|
||||
Returns:
|
||||
True if an index column should be created for this side
|
||||
"""
|
||||
# Must have both supported and unsupported columns to need indexing.
|
||||
# We cannot rely on row_count because it can return a non-zero row count
|
||||
# for an empty-schema.
|
||||
if len(supported_table.schema) == 0 or len(unsupported_table.schema) == 0:
|
||||
return False
|
||||
|
||||
# For semi/anti joins, only index the side that appears in the result
|
||||
if side == "left":
|
||||
# Left side appears in result for all joins except right_semi/right_anti
|
||||
return join_type not in [JoinType.RIGHT_SEMI, JoinType.RIGHT_ANTI]
|
||||
else: # side == "right"
|
||||
# Right side appears in result for all joins except left_semi/left_anti
|
||||
return join_type not in [JoinType.LEFT_SEMI, JoinType.LEFT_ANTI]
|
||||
|
||||
|
||||
def _split_unsupported_columns(
|
||||
table: "pa.Table",
|
||||
) -> Tuple["pa.Table", "pa.Table"]:
|
||||
"""
|
||||
Split a PyArrow table into two tables based on column joinability.
|
||||
|
||||
Separates columns into supported types and unsupported types that cannot be
|
||||
directly joined on but should be preserved in results.
|
||||
|
||||
Args:
|
||||
table: Input PyArrow table to split
|
||||
|
||||
Returns:
|
||||
Tuple of (supported_table, unsupported_table) where:
|
||||
- supported_table contains columns with primitive/joinable types
|
||||
- unsupported_table contains columns with complex/unjoinable types
|
||||
"""
|
||||
supported, unsupported = [], []
|
||||
for idx in range(len(table.columns)):
|
||||
col: "pa.ChunkedArray" = table.column(idx)
|
||||
col_type: "pa.DataType" = col.type
|
||||
|
||||
if _is_pa_extension_type(col_type) or JoinOperator._is_pa_join_not_supported(
|
||||
col_type
|
||||
):
|
||||
unsupported.append(idx)
|
||||
else:
|
||||
supported.append(idx)
|
||||
|
||||
return table.select(supported), table.select(unsupported)
|
||||
|
||||
|
||||
def _add_back_unsupported_columns(
|
||||
joined_table: "pa.Table",
|
||||
unsupported_table: "pa.Table",
|
||||
index_col_name: str,
|
||||
) -> "pa.Table":
|
||||
# Extract the index column array and drop the column from the joined table
|
||||
i = joined_table.schema.get_field_index(index_col_name)
|
||||
indices = joined_table.column(i)
|
||||
joined_table = joined_table.remove_column(i)
|
||||
|
||||
# Project the unsupported columns using the indices and combine with joined table
|
||||
projected = ArrowBlockAccessor(unsupported_table).take(indices)
|
||||
return ArrowBlockAccessor(joined_table).hstack(projected)
|
||||
|
||||
|
||||
def _append_index_column(table: "pa.Table", col_name: str) -> "pa.Table":
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
|
||||
index_col = pa.array(np.arange(table.num_rows))
|
||||
return table.append_column(col_name, index_col)
|
||||
|
||||
|
||||
class JoinOperator(HashShufflingOperatorBase):
|
||||
def __init__(
|
||||
self,
|
||||
data_context: DataContext,
|
||||
left_input_op: PhysicalOperator,
|
||||
right_input_op: PhysicalOperator,
|
||||
left_key_columns: Tuple[str],
|
||||
right_key_columns: Tuple[str],
|
||||
join_type: JoinType,
|
||||
*,
|
||||
num_partitions: Optional[int] = None,
|
||||
left_columns_suffix: Optional[str] = None,
|
||||
right_columns_suffix: Optional[str] = None,
|
||||
partition_size_hint: Optional[int] = None,
|
||||
aggregator_ray_remote_args_override: Optional[Dict[str, Any]] = None,
|
||||
shuffle_aggregation_type: Optional[Type[ShuffleAggregation]] = None,
|
||||
):
|
||||
# Use new stateless JoiningAggregation factory
|
||||
def _create_joining_aggregation() -> JoiningAggregation:
|
||||
if shuffle_aggregation_type is not None:
|
||||
if not issubclass(shuffle_aggregation_type, ShuffleAggregation):
|
||||
raise TypeError(
|
||||
f"shuffle_aggregation_type must be a subclass of {ShuffleAggregation}, "
|
||||
f"got {shuffle_aggregation_type}"
|
||||
)
|
||||
|
||||
aggregation_class = shuffle_aggregation_type or JoiningAggregation
|
||||
|
||||
return aggregation_class(
|
||||
join_type=join_type,
|
||||
left_key_col_names=left_key_columns,
|
||||
right_key_col_names=right_key_columns,
|
||||
left_columns_suffix=left_columns_suffix,
|
||||
right_columns_suffix=right_columns_suffix,
|
||||
data_context=data_context,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
name_factory=(
|
||||
lambda num_partitions: f"Join(num_partitions={num_partitions})"
|
||||
),
|
||||
input_ops=[left_input_op, right_input_op],
|
||||
data_context=data_context,
|
||||
key_columns=[left_key_columns, right_key_columns],
|
||||
num_input_seqs=2,
|
||||
num_partitions=num_partitions,
|
||||
partition_size_hint=partition_size_hint,
|
||||
partition_aggregation_factory=_create_joining_aggregation,
|
||||
aggregator_ray_remote_args_override=aggregator_ray_remote_args_override,
|
||||
shuffle_progress_bar_name="Shuffle",
|
||||
finalize_progress_bar_name="Join",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_pa_join_not_supported(dtype: "pa.DataType") -> bool:
|
||||
"""
|
||||
The latest pyarrow versions do not support joins where the
|
||||
tables contain the following types below (lists,
|
||||
structs, maps, unions, extension types, etc.)
|
||||
|
||||
Args:
|
||||
dtype: The input type of column.
|
||||
|
||||
Returns:
|
||||
True if the type cannot be present (non join-key) during joins.
|
||||
False if the type can be present.
|
||||
"""
|
||||
import pyarrow as pa
|
||||
|
||||
pyarrow_version = get_pyarrow_version()
|
||||
is_v12 = pyarrow_version >= MIN_PYARROW_VERSION_RUN_END_ENCODED_TYPES
|
||||
is_v16 = pyarrow_version >= MIN_PYARROW_VERSION_VIEW_TYPES
|
||||
|
||||
return (
|
||||
pa.types.is_map(dtype)
|
||||
or pa.types.is_union(dtype)
|
||||
or pa.types.is_list(dtype)
|
||||
or pa.types.is_struct(dtype)
|
||||
or pa.types.is_null(dtype)
|
||||
or pa.types.is_large_list(dtype)
|
||||
or pa.types.is_fixed_size_list(dtype)
|
||||
or (is_v12 and pa.types.is_run_end_encoded(dtype))
|
||||
or (
|
||||
is_v16
|
||||
and (
|
||||
pa.types.is_binary_view(dtype)
|
||||
or pa.types.is_string_view(dtype)
|
||||
or pa.types.is_list_view(dtype)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def _get_operator_num_cpus_override(self) -> float:
|
||||
return self.data_context.join_operator_actor_num_cpus_override
|
||||
|
||||
@classmethod
|
||||
def _estimate_aggregator_memory_allocation(
|
||||
cls,
|
||||
*,
|
||||
num_aggregators: int,
|
||||
num_partitions: int,
|
||||
estimated_dataset_bytes: int,
|
||||
) -> int:
|
||||
partition_byte_size_estimate = math.ceil(
|
||||
estimated_dataset_bytes / num_partitions
|
||||
)
|
||||
|
||||
# Estimate of object store memory required to accommodate all partitions
|
||||
# handled by a single aggregator
|
||||
aggregator_shuffle_object_store_memory_required: int = math.ceil(
|
||||
estimated_dataset_bytes / num_aggregators
|
||||
)
|
||||
# Estimate of memory required to perform actual (in-memory) join
|
||||
# operation (inclusive of 50% overhead allocated for Pyarrow join
|
||||
# implementation)
|
||||
#
|
||||
# NOTE:
|
||||
# - 2x due to budgeted 100% overhead of Arrow's in-memory join
|
||||
join_memory_required: int = math.ceil(partition_byte_size_estimate * 2)
|
||||
# Estimate of memory required to accommodate single partition as an output
|
||||
# (inside Object Store)
|
||||
#
|
||||
# NOTE: x2 due to 2 sequences involved in joins
|
||||
output_object_store_memory_required: int = partition_byte_size_estimate
|
||||
|
||||
aggregator_total_memory_required: int = (
|
||||
# Inputs (object store)
|
||||
aggregator_shuffle_object_store_memory_required
|
||||
+
|
||||
# Join (heap)
|
||||
join_memory_required
|
||||
+
|
||||
# Output (object store)
|
||||
output_object_store_memory_required
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Estimated memory requirement for joining aggregator "
|
||||
f"(partitions={num_partitions}, "
|
||||
f"aggregators={num_aggregators}, "
|
||||
f"dataset (estimate)={estimated_dataset_bytes / GiB:.1f}GiB): "
|
||||
f"shuffle={aggregator_shuffle_object_store_memory_required / MiB:.1f}MiB, "
|
||||
f"joining={join_memory_required / MiB:.1f}MiB, "
|
||||
f"output={output_object_store_memory_required / MiB:.1f}MiB, "
|
||||
f"total={aggregator_total_memory_required / MiB:.1f}MiB, "
|
||||
)
|
||||
|
||||
return aggregator_total_memory_required
|
||||
@@ -0,0 +1,147 @@
|
||||
from collections import deque
|
||||
from dataclasses import replace
|
||||
from typing import Deque, List, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
BlockEntry,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.execution.operators.base_physical_operator import (
|
||||
OneToOneOperator,
|
||||
)
|
||||
from ray.data._internal.remote_fn import cached_remote_fn
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.block import Block, BlockAccessor, BlockMetadata, BlockStats
|
||||
from ray.data.context import DataContext
|
||||
from ray.types import ObjectRef
|
||||
|
||||
|
||||
class LimitOperator(OneToOneOperator):
|
||||
"""Physical operator for limit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int,
|
||||
input_op: PhysicalOperator,
|
||||
data_context: DataContext,
|
||||
):
|
||||
self._limit = limit
|
||||
self._consumed_rows = 0
|
||||
self._buffer: Deque[RefBundle] = deque()
|
||||
self._name = f"limit={limit}"
|
||||
self._output_blocks_stats: List[BlockStats] = []
|
||||
self._cur_output_bundles = 0
|
||||
super().__init__(self._name, input_op, data_context)
|
||||
if self._limit <= 0:
|
||||
self.mark_execution_finished()
|
||||
|
||||
def _limit_reached(self) -> bool:
|
||||
return self._consumed_rows >= self._limit
|
||||
|
||||
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
||||
assert not self.has_completed()
|
||||
assert input_index == 0, input_index
|
||||
if self._limit_reached():
|
||||
return
|
||||
out_blocks: List[ObjectRef[Block]] = []
|
||||
out_metadata: List[BlockMetadata] = []
|
||||
for entry in refs.blocks:
|
||||
block = entry.ref
|
||||
metadata = entry.metadata
|
||||
num_rows = metadata.num_rows
|
||||
assert num_rows is not None
|
||||
if self._consumed_rows + num_rows <= self._limit:
|
||||
out_blocks.append(block)
|
||||
out_metadata.append(metadata)
|
||||
self._output_blocks_stats.append(metadata.to_stats())
|
||||
self._consumed_rows += num_rows
|
||||
else:
|
||||
# Slice the last block.
|
||||
def slice_fn(block, metadata, num_rows) -> Tuple[Block, BlockMetadata]:
|
||||
block = BlockAccessor.for_block(block).slice(
|
||||
0, num_rows, copy=False
|
||||
)
|
||||
metadata = replace(
|
||||
metadata,
|
||||
num_rows=num_rows,
|
||||
size_bytes=BlockAccessor.for_block(block).size_bytes(),
|
||||
)
|
||||
return block, metadata
|
||||
|
||||
slice_task = cached_remote_fn(slice_fn, num_cpus=0, num_returns=2)
|
||||
label_selector = self.data_context.execution_options.label_selector
|
||||
if label_selector:
|
||||
slice_task = slice_task.options(label_selector=label_selector)
|
||||
block, metadata_ref = slice_task.remote(
|
||||
block,
|
||||
metadata,
|
||||
self._limit - self._consumed_rows,
|
||||
)
|
||||
out_blocks.append(block)
|
||||
metadata = ray.get(metadata_ref)
|
||||
# Slicing creates a new block; register it for memory tracking.
|
||||
self._block_ref_counter.on_block_produced(
|
||||
block, metadata.size_bytes or 0, self.id
|
||||
)
|
||||
out_metadata.append(metadata)
|
||||
self._output_blocks_stats.append(metadata.to_stats())
|
||||
self._consumed_rows = self._limit
|
||||
break
|
||||
self._cur_output_bundles += 1
|
||||
out_refs = RefBundle(
|
||||
[BlockEntry(b, m) for b, m in zip(out_blocks, out_metadata)],
|
||||
owns_blocks=refs.owns_blocks,
|
||||
schema=refs.schema,
|
||||
)
|
||||
self._buffer.append(out_refs)
|
||||
self._metrics.on_output_queued(out_refs)
|
||||
if self._limit_reached():
|
||||
self.mark_execution_finished()
|
||||
|
||||
# We cannot estimate if we have only consumed empty blocks,
|
||||
# or if the input dependency's total number of output bundles is unknown.
|
||||
num_inputs = self.input_dependencies[0].num_outputs_total()
|
||||
if self._consumed_rows > 0 and num_inputs is not None:
|
||||
# Estimate number of output bundles
|
||||
# Check the case where _limit > # of input rows
|
||||
estimated_total_output_rows = min(
|
||||
self._limit, self._consumed_rows / self._cur_output_bundles * num_inputs
|
||||
)
|
||||
# _consumed_rows / _limit is roughly equal to
|
||||
# _cur_output_bundles / total output blocks
|
||||
self._estimated_num_output_bundles = round(
|
||||
estimated_total_output_rows
|
||||
/ self._consumed_rows
|
||||
* self._cur_output_bundles
|
||||
)
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return len(self._buffer) > 0
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
output = self._buffer.popleft()
|
||||
self._metrics.on_output_dequeued(output)
|
||||
return output
|
||||
|
||||
def get_stats(self) -> StatsDict:
|
||||
return {self._name: self._output_blocks_stats}
|
||||
|
||||
def num_outputs_total(self) -> Optional[int]:
|
||||
# Before execution is completed, we don't know how many output
|
||||
# bundles we will have. We estimate based off the consumption so far.
|
||||
if self.has_execution_finished():
|
||||
return self._cur_output_bundles
|
||||
return self._estimated_num_output_bundles
|
||||
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
# The total number of rows is simply the limit or the number
|
||||
# of input rows, whichever is smaller
|
||||
input_num_rows = self.input_dependencies[0].num_output_rows_total()
|
||||
if input_num_rows is None:
|
||||
return None
|
||||
return min(self._limit, input_num_rows)
|
||||
|
||||
def throttling_disabled(self) -> bool:
|
||||
return True
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,605 @@
|
||||
import itertools
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
from ray._common.utils import env_integer
|
||||
from ray.data._internal.block_batching.block_batching import batch_blocks
|
||||
from ray.data._internal.execution.interfaces.task_context import TaskContext
|
||||
from ray.data._internal.output_buffer import BlockOutputBuffer, OutputBlockSizeOption
|
||||
from ray.data.block import (
|
||||
BatchFormat,
|
||||
Block,
|
||||
BlockAccessor,
|
||||
CustomOpStats,
|
||||
DataBatch,
|
||||
)
|
||||
|
||||
_DEFAULT_BATCH_SIZE_BYTES: int = env_integer(
|
||||
"RAY_DATA_DEFAULT_BATCH_SIZE_BYTES", 16 * 1024 * 1024 # 16 MiB
|
||||
)
|
||||
|
||||
# Allowed input/output data types for a MapTransformFn.
|
||||
Row = Dict[str, Any]
|
||||
MapTransformFnData = Union[Block, Row, DataBatch]
|
||||
|
||||
|
||||
class CustomOpStatsReporter:
|
||||
"""Per-task reporter that carries transforms' :class:`CustomOpStats`.
|
||||
|
||||
``_map_task`` creates one per task and threads it into the transform chain.
|
||||
Each producing transform calls ``op_stats_reporter.report(stats)`` once,
|
||||
before yielding output blocks, to append its :class:`CustomOpStats` to the
|
||||
reporter. Fused transforms each contribute one entry, so the reporter holds a
|
||||
list. ``_map_task`` reads :meth:`get_stats` after each output block and stamps
|
||||
the list onto the block metadata as part of ``TaskExecWorkerStats``
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._stats: List[CustomOpStats] = []
|
||||
|
||||
def report(self, stats: CustomOpStats) -> None:
|
||||
"""Append a producing transform's per-task CustomOpStats."""
|
||||
self._stats.append(stats)
|
||||
|
||||
def get_stats(self) -> List[CustomOpStats]:
|
||||
"""Return all reported CustomOpStats (empty if none were reported)."""
|
||||
return self._stats
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Drop any reported stats (called before each task attempt)."""
|
||||
self._stats = []
|
||||
|
||||
|
||||
# Narrow callback handed to producing transforms to report per-task
|
||||
# :class:`CustomOpStats`.
|
||||
CustomOpStatsReportFn = Callable[[CustomOpStats], None]
|
||||
|
||||
|
||||
def _noop_report_custom_op_stats(stats: CustomOpStats) -> None:
|
||||
"""Stateless default report callback for callers that don't collect stats."""
|
||||
|
||||
|
||||
IN = TypeVar("IN")
|
||||
OUT = TypeVar("OUT")
|
||||
# A transform callable accepts either ``(data, ctx)`` or, when it reports
|
||||
# per-task CustomOpStats, ``(data, ctx, report_custom_op_stats)``.
|
||||
MapTransformCallable = Union[
|
||||
Callable[[Iterable[IN], TaskContext], Iterable[OUT]],
|
||||
Callable[[Iterable[IN], TaskContext, CustomOpStatsReportFn], Iterable[OUT]],
|
||||
]
|
||||
|
||||
|
||||
class MapTransformFnDataType(Enum):
|
||||
"""An enum that represents the input/output data type of a MapTransformFn."""
|
||||
|
||||
Block = 0
|
||||
Row = 1
|
||||
Batch = 2
|
||||
|
||||
|
||||
class MapTransformFn(ABC):
|
||||
"""Represents a single transform function in a MapTransformer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fn: Callable,
|
||||
input_type: MapTransformFnDataType,
|
||||
*,
|
||||
is_udf: bool = False,
|
||||
output_block_size_option: Optional[OutputBlockSizeOption] = None,
|
||||
reports_custom_op_stats: bool = False,
|
||||
):
|
||||
"""Initialize a :class:`MapTransformFn`.
|
||||
|
||||
Args:
|
||||
fn: The wrapped transform callable. Invoked with ``(data, ctx)``, or
|
||||
``(data, ctx, report_custom_op_stats)`` when
|
||||
``reports_custom_op_stats=True``.
|
||||
input_type: Expected type of the input data.
|
||||
is_udf: Whether this transformation is UDF or not.
|
||||
output_block_size_option: (Optional) Output block size configuration.
|
||||
reports_custom_op_stats: If ``True``, the wrapped callable accepts a
|
||||
third ``report_custom_op_stats`` callback argument and may report
|
||||
per-task :class:`CustomOpStats` to the driver. Defaults to
|
||||
``False``, in which case the callable is invoked with
|
||||
``(data, ctx)`` only.
|
||||
"""
|
||||
self._fn = fn
|
||||
self._input_type = input_type
|
||||
self._output_block_size_option = output_block_size_option
|
||||
self._is_udf = is_udf
|
||||
self._reports_custom_op_stats = reports_custom_op_stats
|
||||
|
||||
@abstractmethod
|
||||
def _post_process(self, results: Iterable[MapTransformFnData]) -> Iterable[Block]:
|
||||
pass
|
||||
|
||||
def _apply_transform(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
inputs: Iterable[MapTransformFnData],
|
||||
report_custom_op_stats: CustomOpStatsReportFn = _noop_report_custom_op_stats,
|
||||
) -> Iterable[MapTransformFnData]:
|
||||
"""Call the wrapped fn, passing ``report_custom_op_stats`` only if it opted in.
|
||||
|
||||
Keeps the common ``(data, ctx)`` signature for the vast majority of
|
||||
transforms; only those constructed with ``reports_custom_op_stats=True``
|
||||
receive the report callback.
|
||||
"""
|
||||
if self._reports_custom_op_stats:
|
||||
return self._fn(inputs, ctx, report_custom_op_stats)
|
||||
return self._fn(inputs, ctx)
|
||||
|
||||
def _pre_process(self, blocks: Iterable[Block]) -> Iterable[MapTransformFnData]:
|
||||
return blocks
|
||||
|
||||
def _shape_blocks(self, results: Iterable[MapTransformFnData]) -> Iterable[Block]:
|
||||
"""Shape results into blocks using a buffer."""
|
||||
return _BlockShapingIterator(
|
||||
results, self._input_type, self._output_block_size_option
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
blocks: Iterable[Block],
|
||||
ctx: TaskContext,
|
||||
report_custom_op_stats: CustomOpStatsReportFn = _noop_report_custom_op_stats,
|
||||
) -> Iterable[Block]:
|
||||
batches = self._pre_process(blocks)
|
||||
results = self._apply_transform(ctx, batches, report_custom_op_stats)
|
||||
return self._post_process(results)
|
||||
|
||||
@property
|
||||
def output_block_size_option(self):
|
||||
return self._output_block_size_option
|
||||
|
||||
def override_target_max_block_size(self, target_max_block_size: Optional[int]):
|
||||
if self._output_block_size_option is not None and (
|
||||
self._output_block_size_option.disable_block_shaping
|
||||
or self._output_block_size_option.target_num_rows_per_block is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"Cannot override target_max_block_size if block shaping is disabled or target_num_rows_per_block is set"
|
||||
)
|
||||
self._output_block_size_option = OutputBlockSizeOption.of(
|
||||
target_max_block_size=target_max_block_size
|
||||
)
|
||||
|
||||
@property
|
||||
def target_max_block_size(self):
|
||||
if self._output_block_size_option is None:
|
||||
return None
|
||||
else:
|
||||
return self._output_block_size_option.target_max_block_size
|
||||
|
||||
@property
|
||||
def target_num_rows_per_block(self):
|
||||
if self._output_block_size_option is None:
|
||||
return None
|
||||
else:
|
||||
return self._output_block_size_option.target_num_rows_per_block
|
||||
|
||||
|
||||
class MapTransformer:
|
||||
"""Encapsulates the data transformation logic of a physical MapOperator.
|
||||
|
||||
A MapTransformer may consist of one or more steps, each of which is represented
|
||||
as a MapTransformFn. The first MapTransformFn must take blocks as input, and
|
||||
the last MapTransformFn must output blocks. The intermediate data types can
|
||||
be blocks, rows, or batches.
|
||||
"""
|
||||
|
||||
class _UDFTimingIterator(Iterator[MapTransformFnData]):
|
||||
"""Iterator that times UDF execution"""
|
||||
|
||||
def __init__(
|
||||
self, input: Iterable[MapTransformFnData], transformer: "MapTransformer"
|
||||
):
|
||||
self._input = input
|
||||
self._transformer = transformer
|
||||
|
||||
def __iter__(self) -> "MapTransformer._UDFTimingIterator":
|
||||
return self
|
||||
|
||||
def __next__(self) -> MapTransformFnData:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
return next(self._input)
|
||||
finally:
|
||||
self._transformer._report_udf_time(time.perf_counter() - start)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transform_fns: List[MapTransformFn],
|
||||
*,
|
||||
init_fn: Optional[Callable[[], None]] = None,
|
||||
output_block_size_option_override: Optional[OutputBlockSizeOption] = None,
|
||||
):
|
||||
"""Initialize a :class:`MapTransformer`.
|
||||
|
||||
Args:
|
||||
transform_fns: A list of `MapTransformFn`s that will be executed sequentially
|
||||
to transform data.
|
||||
init_fn: A function that will be called before transforming data.
|
||||
Used for the actor-based map operator.
|
||||
output_block_size_option_override: (Optional) Output block size configuration.
|
||||
"""
|
||||
|
||||
self._transform_fns: List[MapTransformFn] = []
|
||||
self._init_fn = init_fn if init_fn is not None else lambda: None
|
||||
self._output_block_size_option_override = output_block_size_option_override
|
||||
self._udf_time_s = 0
|
||||
|
||||
# Add transformations
|
||||
self.add_transform_fns(transform_fns)
|
||||
|
||||
def add_transform_fns(self, transform_fns: List[MapTransformFn]) -> None:
|
||||
"""Set the transform functions."""
|
||||
assert len(transform_fns) > 0
|
||||
self._transform_fns = self._combine_transformations(
|
||||
self._transform_fns, transform_fns
|
||||
)
|
||||
|
||||
def get_transform_fns(self) -> List[MapTransformFn]:
|
||||
"""Get the transform functions."""
|
||||
return self._transform_fns
|
||||
|
||||
def override_target_max_block_size(self, target_max_block_size: Optional[int]):
|
||||
self._output_block_size_option_override = OutputBlockSizeOption.of(
|
||||
target_max_block_size=target_max_block_size
|
||||
)
|
||||
|
||||
@property
|
||||
def target_max_block_size_override(self) -> Optional[int]:
|
||||
if self._output_block_size_option_override is None:
|
||||
return None
|
||||
else:
|
||||
return self._output_block_size_option_override.target_max_block_size
|
||||
|
||||
def init(self) -> None:
|
||||
"""Initialize the transformer.
|
||||
|
||||
Should be called before applying the transform.
|
||||
"""
|
||||
self._init_fn()
|
||||
|
||||
def apply_transform(
|
||||
self,
|
||||
input_blocks: Iterable[Block],
|
||||
ctx: TaskContext,
|
||||
report_custom_op_stats: CustomOpStatsReportFn = _noop_report_custom_op_stats,
|
||||
) -> Iterable[Block]:
|
||||
"""Apply the transform functions to the input blocks.
|
||||
|
||||
Args:
|
||||
input_blocks: The blocks to transform.
|
||||
ctx: The task context for this transform.
|
||||
report_custom_op_stats: Callback a producing transform calls to report
|
||||
its :class:`CustomOpStats`. ``_map_task`` passes its reporter's
|
||||
``report``; defaults to a stateless no-op for callers (e.g. tests)
|
||||
that don't collect custom stats.
|
||||
|
||||
Returns:
|
||||
An iterable of the transformed output blocks.
|
||||
"""
|
||||
# NOTE: We only need to configure last transforming function to do
|
||||
# appropriate block sizing
|
||||
last_transform = self._transform_fns[-1]
|
||||
|
||||
if self.target_max_block_size_override is not None:
|
||||
last_transform.override_target_max_block_size(
|
||||
self.target_max_block_size_override
|
||||
)
|
||||
|
||||
iter = input_blocks
|
||||
# Apply the transform functions sequentially to the input iterable.
|
||||
for transform_fn in self._transform_fns:
|
||||
iter = transform_fn(iter, ctx, report_custom_op_stats)
|
||||
if transform_fn._is_udf:
|
||||
iter = self._UDFTimingIterator(iter, self)
|
||||
|
||||
return iter
|
||||
|
||||
def fuse(self, other: "MapTransformer") -> "MapTransformer":
|
||||
"""Fuse two `MapTransformer`s together."""
|
||||
assert (
|
||||
self.target_max_block_size_override == other.target_max_block_size_override
|
||||
or (
|
||||
self.target_max_block_size_override is None
|
||||
or other.target_max_block_size_override is None
|
||||
)
|
||||
)
|
||||
# Define them as standalone variables to avoid fused_init_fn capturing the
|
||||
# entire `MapTransformer` object.
|
||||
self_init_fn = self._init_fn
|
||||
other_init_fn = other._init_fn
|
||||
|
||||
def fused_init_fn():
|
||||
self_init_fn()
|
||||
other_init_fn()
|
||||
|
||||
combined_transform_fns = self._combine_transformations(
|
||||
self._transform_fns,
|
||||
other._transform_fns,
|
||||
)
|
||||
|
||||
transformer = MapTransformer(
|
||||
combined_transform_fns,
|
||||
init_fn=fused_init_fn,
|
||||
output_block_size_option_override=OutputBlockSizeOption.of(
|
||||
target_max_block_size=(
|
||||
self.target_max_block_size_override
|
||||
or other.target_max_block_size_override
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return transformer
|
||||
|
||||
@classmethod
|
||||
def _combine_transformations(
|
||||
cls, ones: List[MapTransformFn], others: List[MapTransformFn]
|
||||
) -> list[Any]:
|
||||
return ones + others
|
||||
|
||||
def udf_time_s(self, reset: bool) -> float:
|
||||
cur_time_s = self._udf_time_s
|
||||
if reset:
|
||||
self._udf_time_s = 0
|
||||
return cur_time_s
|
||||
|
||||
def _report_udf_time(self, udf_time: float) -> None:
|
||||
self._udf_time_s += udf_time
|
||||
|
||||
|
||||
class RowMapTransformFn(MapTransformFn):
|
||||
"""A rows-to-rows MapTransformFn."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
row_fn: MapTransformCallable[Row, Row],
|
||||
*,
|
||||
is_udf: bool = False,
|
||||
output_block_size_option: OutputBlockSizeOption,
|
||||
reports_custom_op_stats: bool = False,
|
||||
):
|
||||
super().__init__(
|
||||
row_fn,
|
||||
input_type=MapTransformFnDataType.Row,
|
||||
is_udf=is_udf,
|
||||
output_block_size_option=output_block_size_option,
|
||||
reports_custom_op_stats=reports_custom_op_stats,
|
||||
)
|
||||
|
||||
def _pre_process(self, blocks: Iterable[Block]) -> Iterable[MapTransformFnData]:
|
||||
return _RowBasedIterator(blocks)
|
||||
|
||||
def _post_process(self, results: Iterable[MapTransformFnData]) -> Iterable[Block]:
|
||||
return self._shape_blocks(results)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RowMapTransformFn({self._fn})"
|
||||
|
||||
|
||||
def _peek_first_nonempty_block(
|
||||
blocks: Iterable[Block],
|
||||
) -> Tuple[Optional[BlockAccessor], Iterable[Block]]:
|
||||
"""Advance the iterator past leading empty blocks to find the first non-empty block,
|
||||
returning the corresponding accessor and a reconstructed iterator of all blocks.
|
||||
We must reconstruct the iterator because we consume blocks as we advance through the iterator."""
|
||||
blocks_iter = iter(blocks)
|
||||
consumed = []
|
||||
for block in blocks_iter:
|
||||
consumed.append(block)
|
||||
accessor = BlockAccessor.for_block(block)
|
||||
if accessor.num_rows() > 0 and accessor.size_bytes() > 0:
|
||||
return accessor, itertools.chain(consumed, blocks_iter)
|
||||
return None, iter(consumed)
|
||||
|
||||
|
||||
def _compute_auto_batch_size(
|
||||
blocks: Iterable[Block],
|
||||
target_batch_size_bytes: int = _DEFAULT_BATCH_SIZE_BYTES,
|
||||
) -> Tuple[Optional[int], Iterable[Block]]:
|
||||
"""Peek at the first non-empty block to estimate the batch size to use for the
|
||||
'auto' batch_size option."""
|
||||
sample, blocks = _peek_first_nonempty_block(blocks)
|
||||
if sample is None:
|
||||
return None, blocks
|
||||
bytes_per_row = sample.size_bytes() / sample.num_rows()
|
||||
computed_batch_size = max(1, int(target_batch_size_bytes / bytes_per_row))
|
||||
return computed_batch_size, blocks
|
||||
|
||||
|
||||
class BatchMapTransformFn(MapTransformFn):
|
||||
"""A batch-to-batch MapTransformFn."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
batch_fn: MapTransformCallable[DataBatch, DataBatch],
|
||||
*,
|
||||
is_udf: bool = False,
|
||||
batch_size: Union[Optional[int], Literal["auto"]] = None,
|
||||
batch_format: Optional[BatchFormat] = None,
|
||||
zero_copy_batch: bool = True,
|
||||
output_block_size_option: Optional[OutputBlockSizeOption] = None,
|
||||
target_batch_size_bytes: int = _DEFAULT_BATCH_SIZE_BYTES,
|
||||
reports_custom_op_stats: bool = False,
|
||||
):
|
||||
super().__init__(
|
||||
batch_fn,
|
||||
input_type=MapTransformFnDataType.Batch,
|
||||
is_udf=is_udf,
|
||||
output_block_size_option=output_block_size_option,
|
||||
reports_custom_op_stats=reports_custom_op_stats,
|
||||
)
|
||||
|
||||
self._batch_size = batch_size
|
||||
self._batch_format = batch_format
|
||||
self._zero_copy_batch = zero_copy_batch
|
||||
self._target_batch_size_bytes = target_batch_size_bytes
|
||||
|
||||
def _pre_process(self, blocks: Iterable[Block]) -> Iterable[MapTransformFnData]:
|
||||
# TODO make batch-udf zero-copy by default
|
||||
if self._batch_size == "auto":
|
||||
batch_size, blocks = _compute_auto_batch_size(
|
||||
blocks, target_batch_size_bytes=self._target_batch_size_bytes
|
||||
)
|
||||
else:
|
||||
batch_size = self._batch_size
|
||||
ensure_copy = not self._zero_copy_batch and batch_size is not None
|
||||
return batch_blocks(
|
||||
blocks=iter(blocks),
|
||||
stats=None,
|
||||
batch_size=batch_size,
|
||||
batch_format=self._batch_format,
|
||||
ensure_copy=ensure_copy,
|
||||
)
|
||||
|
||||
def _post_process(self, results: Iterable[MapTransformFnData]) -> Iterable[Block]:
|
||||
return self._shape_blocks(results)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"BatchMapTransformFn({self._fn=}, {self._batch_format=}, {self._batch_size=}, {self._zero_copy_batch=})"
|
||||
|
||||
|
||||
class BlockMapTransformFn(MapTransformFn):
|
||||
"""A block-to-block MapTransformFn."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
block_fn: MapTransformCallable[Block, Block],
|
||||
*,
|
||||
is_udf: bool = False,
|
||||
disable_block_shaping: bool = False,
|
||||
output_block_size_option: Optional[OutputBlockSizeOption] = None,
|
||||
reports_custom_op_stats: bool = False,
|
||||
):
|
||||
"""
|
||||
Initializes the object with a transformation function, accompanying options, and
|
||||
configuration for handling blocks during processing.
|
||||
|
||||
Args:
|
||||
block_fn: Callable function to apply a transformation to a block.
|
||||
is_udf: Specifies if the transformation function is a user-defined
|
||||
function (defaults to ``False``).
|
||||
disable_block_shaping: Disables block-shaping, making transformer to
|
||||
produce blocks as is.
|
||||
output_block_size_option: (Optional) Configure output block sizing.
|
||||
reports_custom_op_stats: If ``True``, ``block_fn`` accepts a third
|
||||
``report_custom_op_stats`` callback argument and may report
|
||||
per-task :class:`CustomOpStats` to the driver.
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
block_fn,
|
||||
input_type=MapTransformFnDataType.Block,
|
||||
is_udf=is_udf,
|
||||
output_block_size_option=output_block_size_option,
|
||||
reports_custom_op_stats=reports_custom_op_stats,
|
||||
)
|
||||
|
||||
self._disable_block_shaping = disable_block_shaping
|
||||
|
||||
def _post_process(self, results: Iterable[MapTransformFnData]) -> Iterable[Block]:
|
||||
# Short-circuit for block transformations for which no
|
||||
# block-shaping is required
|
||||
if self._disable_block_shaping:
|
||||
return results
|
||||
|
||||
return self._shape_blocks(results)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"BlockMapTransformFn({self._fn=}, {self._output_block_size_option=})"
|
||||
|
||||
|
||||
class _BlockShapingIterator(Iterator[Block]):
|
||||
"""Iterator that shapes results into blocks using a buffer.
|
||||
|
||||
Unlike a generator, local variables in __next__ go out of scope when the method
|
||||
returns, avoiding holding references to yielded values.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
results: Iterable[MapTransformFnData],
|
||||
input_type: MapTransformFnDataType,
|
||||
output_block_size_option: Optional[OutputBlockSizeOption],
|
||||
):
|
||||
self._results_iter = iter(results)
|
||||
self._buffer = BlockOutputBuffer(output_block_size_option)
|
||||
self._finalized = False
|
||||
|
||||
if input_type == MapTransformFnDataType.Block:
|
||||
self._append_buffer = self._buffer.add_block
|
||||
elif input_type == MapTransformFnDataType.Batch:
|
||||
self._append_buffer = self._buffer.add_batch
|
||||
else:
|
||||
assert input_type == MapTransformFnDataType.Row
|
||||
self._append_buffer = self._buffer.add
|
||||
|
||||
def __iter__(self) -> "_BlockShapingIterator":
|
||||
return self
|
||||
|
||||
def __next__(self) -> Block:
|
||||
while True:
|
||||
# First, yield any ready blocks from buffer
|
||||
if self._buffer.has_next():
|
||||
return self._buffer.next()
|
||||
|
||||
# If finalized, no more data
|
||||
elif self._finalized:
|
||||
raise StopIteration
|
||||
|
||||
try:
|
||||
# Fetch more results
|
||||
result = next(self._results_iter)
|
||||
self._append_buffer(result)
|
||||
except StopIteration:
|
||||
self._buffer.finalize()
|
||||
self._finalized = True
|
||||
|
||||
|
||||
class _RowBasedIterator(Iterator[Row]):
|
||||
"""Iterator that extracts rows from blocks.
|
||||
|
||||
Unlike a generator, local variables in __next__ go out of scope when the method
|
||||
returns, avoiding holding references to yielded values.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: Iterable[Block]):
|
||||
self._blocks_iter = iter(blocks)
|
||||
self._cur_row_iter: Optional[Iterator[Row]] = None
|
||||
|
||||
def __iter__(self) -> "_RowBasedIterator":
|
||||
return self
|
||||
|
||||
def __next__(self) -> Row:
|
||||
while True:
|
||||
# Try to get next row from current block
|
||||
if self._cur_row_iter is not None:
|
||||
try:
|
||||
return next(self._cur_row_iter)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
# Get iterator from the next block
|
||||
block = next(self._blocks_iter)
|
||||
|
||||
self._cur_row_iter = BlockAccessor.for_block(block).iter_rows(
|
||||
public_row_format=True
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from ray.data._internal.execution.bundle_queue import BaseBundleQueue, FIFOBundleQueue
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.execution.operators.base_physical_operator import (
|
||||
InternalQueueOperatorMixin,
|
||||
NAryOperator,
|
||||
)
|
||||
from ray.data._internal.logical.operators.n_ary_operator import (
|
||||
MixStoppingCondition,
|
||||
estimate_num_mix_outputs,
|
||||
)
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.context import DataContext
|
||||
|
||||
|
||||
class MixOperator(InternalQueueOperatorMixin, NAryOperator):
|
||||
"""An operator that interleaves blocks from multiple input operators
|
||||
into a single output stream, respecting target row ratios specified
|
||||
by weights.
|
||||
|
||||
Tracks cumulative row counts per input and always pulls from whichever
|
||||
input has fallen furthest behind its target ratio. This ensures the
|
||||
output row ratio converges to the target weights regardless of input
|
||||
block size variance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_context: DataContext,
|
||||
*input_ops: PhysicalOperator,
|
||||
weights: List[float],
|
||||
stopping_condition: MixStoppingCondition = MixStoppingCondition.STOP_ON_SHORTEST,
|
||||
):
|
||||
assert len(input_ops) >= 1
|
||||
assert len(weights) == len(input_ops)
|
||||
if any(w <= 0 for w in weights):
|
||||
raise ValueError("Weights must be positive.")
|
||||
|
||||
total_weight = sum(weights)
|
||||
self._weights = [w / total_weight for w in weights]
|
||||
|
||||
self._stopping_condition = stopping_condition
|
||||
|
||||
self._input_buffers: List[BaseBundleQueue] = [
|
||||
FIFOBundleQueue() for _ in range(len(input_ops))
|
||||
]
|
||||
self._output_buffer: BaseBundleQueue = FIFOBundleQueue()
|
||||
|
||||
# Cumulative rows output per input, used for deficit calculation.
|
||||
self._rows_seen: List[int] = [0] * len(input_ops)
|
||||
self._input_done_flags: List[bool] = [False] * len(input_ops)
|
||||
self._stopped: bool = False
|
||||
|
||||
self._stats: StatsDict = {"Mix": []}
|
||||
|
||||
input_names = ", ".join([op._name for op in input_ops])
|
||||
weights_str = [round(w, 2) for w in self._weights]
|
||||
name = f"Mix({input_names}, weights={weights_str})"
|
||||
super().__init__(data_context, *input_ops, name=name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# InternalQueueOperatorMixin interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_queues(self) -> List[BaseBundleQueue]:
|
||||
return self._input_buffers
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_queues(self) -> List[BaseBundleQueue]:
|
||||
return [self._output_buffer]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PhysicalOperator interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
def mark_execution_finished(self) -> None:
|
||||
# Override InternalQueueOperatorMixin's version to preserve the
|
||||
# output buffer for draining. Only clear input queues.
|
||||
PhysicalOperator.mark_execution_finished(self)
|
||||
self.clear_internal_input_queue()
|
||||
|
||||
@override
|
||||
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
||||
assert not self.has_completed()
|
||||
assert 0 <= input_index < len(self._input_dependencies), input_index
|
||||
if self._stopped:
|
||||
return
|
||||
self._input_buffers[input_index].add(refs)
|
||||
self._metrics.on_input_queued(refs, input_index=input_index)
|
||||
self._try_output()
|
||||
|
||||
@override
|
||||
def input_done(self, input_index: int) -> None:
|
||||
self._input_done_flags[input_index] = True
|
||||
self._try_output()
|
||||
|
||||
@override
|
||||
def all_inputs_done(self) -> None:
|
||||
super().all_inputs_done()
|
||||
self._try_output()
|
||||
|
||||
@override
|
||||
def has_next(self) -> bool:
|
||||
return len(self._output_buffer) > 0
|
||||
|
||||
@override
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
refs = self._output_buffer.get_next()
|
||||
self._metrics.on_output_dequeued(refs)
|
||||
return refs
|
||||
|
||||
@override
|
||||
def num_outputs_total(self) -> Optional[int]:
|
||||
if self._stopping_condition == MixStoppingCondition.STOP_ON_SHORTEST:
|
||||
# Can't accurately estimate output block count because weights
|
||||
# control row ratios, not block ratios. With non-uniform block
|
||||
# sizes, the block count doesn't follow the weight distribution.
|
||||
return None
|
||||
|
||||
return estimate_num_mix_outputs(
|
||||
[op.num_outputs_total() for op in self.input_dependencies],
|
||||
self._weights,
|
||||
self._stopping_condition,
|
||||
)
|
||||
|
||||
@override
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
return estimate_num_mix_outputs(
|
||||
[op.num_output_rows_total() for op in self.input_dependencies],
|
||||
self._weights,
|
||||
self._stopping_condition,
|
||||
)
|
||||
|
||||
@override
|
||||
def get_stats(self) -> StatsDict:
|
||||
return self._stats
|
||||
|
||||
@override
|
||||
def throttling_disabled(self) -> bool:
|
||||
# TODO: Disable throttling along with Union once NAry operator resource accounting is fixed.
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_input_exhausted(self, index: int) -> bool:
|
||||
"""An input is exhausted when it's done and its buffer is empty."""
|
||||
return (
|
||||
self._input_done_flags[index] and not self._input_buffers[index].has_next()
|
||||
)
|
||||
|
||||
def _select_most_behind_input(self) -> int:
|
||||
"""Select which input to pull from next.
|
||||
|
||||
Returns the index of the non-exhausted input that has fallen furthest
|
||||
behind its target row ratio. Ties are broken by weight (prefer higher),
|
||||
then by index. Returns -1 if all inputs are exhausted.
|
||||
"""
|
||||
total = sum(self._rows_seen)
|
||||
best_index = -1
|
||||
most_behind = float("-inf")
|
||||
best_weight = -1.0
|
||||
|
||||
for i in range(len(self._input_buffers)):
|
||||
if self._is_input_exhausted(i):
|
||||
continue
|
||||
# How far behind this input is: positive means underrepresented.
|
||||
gap = self._weights[i] * total - self._rows_seen[i]
|
||||
# Tie-break by weight.
|
||||
if gap > most_behind or (
|
||||
gap == most_behind and self._weights[i] > best_weight
|
||||
):
|
||||
most_behind = gap
|
||||
best_weight = self._weights[i]
|
||||
best_index = i
|
||||
|
||||
return best_index
|
||||
|
||||
def _try_output(self) -> None:
|
||||
"""Move blocks from input buffers to the output buffer.
|
||||
|
||||
On each iteration, selects the input furthest behind its target ratio.
|
||||
If that input has blocks, one is moved to the output. If not, we wait
|
||||
rather than pulling from a different input — this keeps the output
|
||||
deterministic regardless of block arrival timing.
|
||||
"""
|
||||
if self._stopped:
|
||||
return
|
||||
|
||||
while True:
|
||||
if self._stopping_condition == MixStoppingCondition.STOP_ON_SHORTEST:
|
||||
if any(
|
||||
self._is_input_exhausted(i) for i in range(len(self._input_buffers))
|
||||
):
|
||||
self._stopped = True
|
||||
self.mark_execution_finished()
|
||||
return
|
||||
elif self._stopping_condition != MixStoppingCondition.STOP_ON_LONGEST_DROP:
|
||||
raise ValueError(
|
||||
f"Unknown stopping condition: {self._stopping_condition}"
|
||||
)
|
||||
|
||||
best_index = self._select_most_behind_input()
|
||||
if best_index == -1:
|
||||
return
|
||||
|
||||
if not self._input_buffers[best_index].has_next():
|
||||
# Selected input has no blocks yet — wait rather than
|
||||
# pulling from a lower-deficit input.
|
||||
return
|
||||
|
||||
# Move one block from the selected input to the output buffer.
|
||||
bundle = self._input_buffers[best_index].get_next()
|
||||
self._metrics.on_input_dequeued(bundle, input_index=best_index)
|
||||
|
||||
num_rows = bundle.num_rows()
|
||||
assert num_rows is not None
|
||||
self._rows_seen[best_index] += num_rows
|
||||
|
||||
self._output_buffer.add(bundle)
|
||||
self._metrics.on_output_queued(bundle)
|
||||
@@ -0,0 +1,451 @@
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any, Collection, Dict, List, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from ray._common.utils import env_float
|
||||
from ray.data._internal.execution.bundle_queue import (
|
||||
BaseBundleQueue,
|
||||
FIFOBundleQueue,
|
||||
HashLinkedQueue,
|
||||
)
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
BlockEntry,
|
||||
ExecutionOptions,
|
||||
NodeIdStr,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.execution.operators.base_physical_operator import (
|
||||
InternalQueueOperatorMixin,
|
||||
)
|
||||
from ray.data._internal.execution.util import locality_string
|
||||
from ray.data._internal.remote_fn import cached_remote_fn
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.block import Block, BlockAccessor, BlockMetadata
|
||||
from ray.data.context import DataContext
|
||||
from ray.types import ObjectRef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_OUTPUT_SPLITTER_MAX_BUFFERING_FACTOR = env_float(
|
||||
"RAY_DATA_DEFAULT_OUTPUT_SPLITTER_MAX_BUFFERING_FACTOR", 2
|
||||
)
|
||||
|
||||
|
||||
class OutputSplitter(InternalQueueOperatorMixin, PhysicalOperator):
|
||||
"""An operator that splits the given data into `n` output splits.
|
||||
|
||||
The output bundles of this operator will have a `bundle.output_split_idx` attr
|
||||
set to an integer from [0..n-1]. This operator tries to divide the rows evenly
|
||||
across output splits. If the `equal` option is set, the operator will furthermore
|
||||
guarantee an exact split of rows across outputs, truncating the Dataset.
|
||||
|
||||
Implementation wise, this operator keeps an internal buffer of bundles. The buffer
|
||||
has a minimum size calculated to enable a good locality hit rate, as well as ensure
|
||||
we can satisfy the `equal` requirement.
|
||||
|
||||
OutputSplitter does not provide any ordering guarantees.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_op: PhysicalOperator,
|
||||
n: int,
|
||||
equal: bool,
|
||||
data_context: DataContext,
|
||||
locality_hints: Optional[List[NodeIdStr]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
f"split({n}, equal={equal})",
|
||||
[input_op],
|
||||
data_context,
|
||||
num_output_splits=n,
|
||||
)
|
||||
self._equal = equal
|
||||
# Buffer of bundles not yet assigned to output splits.
|
||||
self._buffer: HashLinkedQueue = HashLinkedQueue()
|
||||
# The outputted bundles with output_split attribute set.
|
||||
self._output_queue: FIFOBundleQueue = FIFOBundleQueue()
|
||||
# The number of rows output to each output split so far.
|
||||
self._num_output: List[int] = [0 for _ in range(n)]
|
||||
# The time of the overhead for the output splitter (operator level)
|
||||
self._output_splitter_overhead_time = 0
|
||||
|
||||
if locality_hints is not None:
|
||||
if n != len(locality_hints):
|
||||
raise ValueError(
|
||||
"Locality hints list must have length `n`: "
|
||||
f"len({locality_hints}) != {n}"
|
||||
)
|
||||
self._locality_hints = locality_hints
|
||||
|
||||
# To optimize locality, we might defer dispatching of the bundles to allow
|
||||
# for better node affinity by allowing next receiver to wait for a block
|
||||
# with preferred locality (minimizing data movement).
|
||||
#
|
||||
# However, to guarantee liveness we cap buffering to not exceed
|
||||
#
|
||||
# DEFAULT_OUTPUT_SPLITTER_MAX_BUFFERING_FACTOR * N
|
||||
#
|
||||
# Where N is the number of outputs the sequence is being split into
|
||||
if locality_hints:
|
||||
self._max_buffer_size = DEFAULT_OUTPUT_SPLITTER_MAX_BUFFERING_FACTOR * n
|
||||
else:
|
||||
self._max_buffer_size = 0
|
||||
|
||||
self._locality_hits = 0
|
||||
self._locality_misses = 0
|
||||
|
||||
logger.debug(
|
||||
f"OutputSplitter created: {n=}, {equal=}, {locality_hints=}, "
|
||||
f"{self._max_buffer_size=}"
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._buffer]
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._output_queue]
|
||||
|
||||
def num_outputs_total(self) -> Optional[int]:
|
||||
# OutputSplitter does not change the number of blocks,
|
||||
# so we can return the number of blocks from the input op.
|
||||
return self.input_dependencies[0].num_outputs_total()
|
||||
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
# The total number of rows is the same as the number of input rows.
|
||||
return self.input_dependencies[0].num_output_rows_total()
|
||||
|
||||
def start(
|
||||
self,
|
||||
options: ExecutionOptions,
|
||||
block_ref_counter: "BlockRefCounter",
|
||||
) -> None:
|
||||
if options.preserve_order:
|
||||
# If preserve_order is set, we need to ignore locality hints to ensure determinism.
|
||||
self._locality_hints = None
|
||||
self._max_buffer_size = 0
|
||||
|
||||
super().start(options, block_ref_counter)
|
||||
|
||||
def throttling_disabled(self) -> bool:
|
||||
"""Disables resource-based throttling.
|
||||
|
||||
It doesn't make sense to throttle the inputs to this operator, since all that
|
||||
would do is lower the buffer size and prevent us from emitting outputs /
|
||||
reduce the locality hit rate.
|
||||
"""
|
||||
return True
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return self._output_queue.has_next()
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
output = self._output_queue.get_next()
|
||||
self._metrics.on_output_dequeued(output)
|
||||
return output
|
||||
|
||||
def get_stats(self) -> StatsDict:
|
||||
return {"split": []} # TODO(ekl) add split metrics?
|
||||
|
||||
def _extra_metrics(self) -> Dict[str, Any]:
|
||||
stats = {}
|
||||
for i, num in enumerate(self._num_output):
|
||||
stats[f"num_output_{i}"] = num
|
||||
stats["output_splitter_overhead_time"] = self._output_splitter_overhead_time
|
||||
return stats
|
||||
|
||||
def _add_input_inner(self, bundle, input_index) -> None:
|
||||
if bundle.num_rows() is None:
|
||||
raise ValueError("OutputSplitter requires bundles with known row count")
|
||||
self._buffer.add(bundle)
|
||||
self._metrics.on_input_queued(bundle, input_index=0)
|
||||
# Try dispatch buffered bundles
|
||||
self._try_dispatch_bundles()
|
||||
|
||||
def all_inputs_done(self) -> None:
|
||||
super().all_inputs_done()
|
||||
|
||||
# First, attempt to dispatch bundles based on the locality preferences
|
||||
# (if configured)
|
||||
if self._locality_hints:
|
||||
# NOTE: If equal distribution is not requested, we will force
|
||||
# the dispatching
|
||||
self._try_dispatch_bundles(force=not self._equal)
|
||||
|
||||
if not self._equal:
|
||||
assert not self._buffer, "All bundles should have been dispatched"
|
||||
return
|
||||
|
||||
if not self._buffer:
|
||||
return
|
||||
|
||||
# Otherwise:
|
||||
# Need to finalize distribution of buffered data to output splits.
|
||||
buffer_size = self._buffer.num_rows()
|
||||
max_n = max(self._num_output)
|
||||
|
||||
# First calculate the min rows to add per output to equalize them.
|
||||
allocation = [max_n - n for n in self._num_output]
|
||||
remainder = buffer_size - sum(allocation)
|
||||
# Invariant: buffer should always be large enough to equalize.
|
||||
assert remainder >= 0, (remainder, buffer_size, allocation)
|
||||
|
||||
# Equally distribute remaining rows in buffer to outputs.
|
||||
x = remainder // len(allocation)
|
||||
allocation = [a + x for a in allocation]
|
||||
|
||||
# Execute the split.
|
||||
for i, count in enumerate(allocation):
|
||||
bundles = self._split_from_buffer(count)
|
||||
for b in bundles:
|
||||
b = replace(b, output_split_idx=i)
|
||||
self._output_queue.add(b)
|
||||
self._metrics.on_output_queued(b)
|
||||
# Drain truncated remainder through the metrics layer.
|
||||
# A bare self._buffer.clear() would bypass on_input_dequeued,
|
||||
# orphaning RefBundle references in _metrics._internal_inqueues
|
||||
# that pin ObjectRefs in the object store.
|
||||
self.clear_internal_input_queue()
|
||||
|
||||
def progress_str(self) -> str:
|
||||
if self._locality_hints:
|
||||
return locality_string(self._locality_hits, self._locality_misses)
|
||||
else:
|
||||
return "[locality disabled]"
|
||||
|
||||
def _try_dispatch_bundles(self, force: bool = False) -> None:
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Currently, there are 2 modes of operation when dispatching
|
||||
# accumulated bundles:
|
||||
#
|
||||
# 1. Best-effort: we do a single pass over the whole buffer
|
||||
# and try to dispatch all bundles either
|
||||
#
|
||||
# a) Based on their locality (if feasible)
|
||||
# b) Longest-waiting if buffer exceeds max-size threshold
|
||||
#
|
||||
# 2. Mandatory: when whole buffer has to be dispatched (for ex,
|
||||
# upon completion of the dataset execution)
|
||||
#
|
||||
for _ in range(len(self._buffer)):
|
||||
# Get target output index of the next receiver
|
||||
target_output_index = self._select_next_output_index()
|
||||
# Look up preferred bundle
|
||||
preferred_bundle = self._find_preferred_bundle(target_output_index)
|
||||
|
||||
if preferred_bundle:
|
||||
target_bundle = preferred_bundle
|
||||
elif len(self._buffer) >= self._max_buffer_size or force:
|
||||
# If we're not able to find a preferred bundle and buffer size is above
|
||||
# the cap, we pop the longest awaiting and pass to the next receiver
|
||||
target_bundle = self._buffer.peek_next()
|
||||
assert target_bundle is not None
|
||||
else:
|
||||
# Provided that we weren't able to either locate preferred bundle
|
||||
# or dequeue the head one, we bail out from iteration
|
||||
break
|
||||
|
||||
# In case, when we can't safely dispatch (to avoid violating distribution
|
||||
# requirements), short-circuit
|
||||
if not self._can_safely_dispatch(
|
||||
target_output_index, target_bundle.num_rows()
|
||||
):
|
||||
break
|
||||
|
||||
# Pop preferred bundle from the buffer
|
||||
self._buffer.remove(target_bundle)
|
||||
self._metrics.on_input_dequeued(target_bundle, input_index=0)
|
||||
|
||||
target_bundle = replace(target_bundle, output_split_idx=target_output_index)
|
||||
|
||||
self._num_output[target_output_index] += target_bundle.num_rows()
|
||||
self._output_queue.add(target_bundle)
|
||||
self._metrics.on_output_queued(target_bundle)
|
||||
|
||||
if self._locality_hints:
|
||||
if preferred_bundle:
|
||||
self._locality_hits += 1
|
||||
else:
|
||||
self._locality_misses += 1
|
||||
|
||||
self._output_splitter_overhead_time += time.perf_counter() - start_time
|
||||
|
||||
def _select_next_output_index(self) -> int:
|
||||
# Greedily dispatch to the consumer with the least data so far.
|
||||
i, _ = min(enumerate(self._num_output), key=lambda t: t[1])
|
||||
return i
|
||||
|
||||
def _find_preferred_bundle(self, target_output_index: int) -> Optional[RefBundle]:
|
||||
if self._locality_hints:
|
||||
preferred_loc = self._locality_hints[target_output_index]
|
||||
|
||||
# TODO make this more efficient (adding inverse hash-map)
|
||||
for bundle in self._buffer:
|
||||
if preferred_loc in self._get_locations(bundle):
|
||||
return bundle
|
||||
|
||||
return None
|
||||
|
||||
def _can_safely_dispatch(self, target_index: int, target_num_rows: int) -> bool:
|
||||
if not self._equal:
|
||||
# If not in equals mode, dispatch away with no buffer requirements.
|
||||
return True
|
||||
|
||||
# Simulate dispatching a bundle to the target receiver
|
||||
output_distribution = self._num_output.copy()
|
||||
output_distribution[target_index] += target_num_rows
|
||||
buffer_requirement = self._calculate_buffer_requirement(output_distribution)
|
||||
# Subtract target bundle size from the projected buffer
|
||||
buffer_size = self._buffer.num_rows() - target_num_rows
|
||||
# Check if we have enough rows LEFT after dispatching to equalize.
|
||||
return buffer_size >= buffer_requirement
|
||||
|
||||
def _calculate_buffer_requirement(self, output_distribution: List[int]) -> int:
|
||||
# Calculate the new number of rows that we'd need to equalize the row
|
||||
# distribution after the bundle dispatch.
|
||||
max_n = max(output_distribution)
|
||||
return sum([max_n - n for n in output_distribution])
|
||||
|
||||
def _split_from_buffer(self, nrow: int) -> List[RefBundle]:
|
||||
output = []
|
||||
acc = 0
|
||||
label_selector = self.data_context.execution_options.label_selector
|
||||
while acc < nrow:
|
||||
b = self._buffer.get_next()
|
||||
self._metrics.on_input_dequeued(b, input_index=0)
|
||||
if acc + b.num_rows() <= nrow:
|
||||
output.append(b)
|
||||
acc += b.num_rows()
|
||||
else:
|
||||
input_refs = {entry.ref for entry in b.blocks}
|
||||
left, right = _split(b, nrow - acc, label_selector)
|
||||
# Only register genuinely new blocks created by _split_block.
|
||||
for part in (left, right):
|
||||
for entry in part.blocks:
|
||||
if entry.ref not in input_refs:
|
||||
self._block_ref_counter.on_block_produced(
|
||||
entry.ref,
|
||||
entry.metadata.size_bytes or 0,
|
||||
self.id,
|
||||
)
|
||||
output.append(left)
|
||||
acc += left.num_rows()
|
||||
self._buffer.add(right)
|
||||
self._metrics.on_input_queued(right, input_index=0)
|
||||
assert acc == nrow, (acc, nrow)
|
||||
|
||||
assert sum(b.num_rows() for b in output) == nrow, (acc, nrow)
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _get_locations(bundle: RefBundle) -> Collection[NodeIdStr]:
|
||||
"""Fetches list of node ids holding the objects of the given bundle.
|
||||
|
||||
This method may be overridden for testing.
|
||||
|
||||
Args:
|
||||
bundle: The ``RefBundle`` whose object locations to look up.
|
||||
|
||||
Returns:
|
||||
A list of node ids where the objects in the bundle are located
|
||||
"""
|
||||
preferred_locations = bundle.get_preferred_object_locations()
|
||||
|
||||
return preferred_locations.keys()
|
||||
|
||||
|
||||
def _split(
|
||||
bundle: RefBundle,
|
||||
left_size: int,
|
||||
label_selector: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[RefBundle, RefBundle]:
|
||||
left_blocks, left_meta = [], []
|
||||
right_blocks, right_meta = [], []
|
||||
acc = 0
|
||||
for entry in bundle.blocks:
|
||||
b = entry.ref
|
||||
m = entry.metadata
|
||||
if acc >= left_size:
|
||||
right_blocks.append(b)
|
||||
right_meta.append(m)
|
||||
elif acc + m.num_rows <= left_size:
|
||||
left_blocks.append(b)
|
||||
left_meta.append(m)
|
||||
acc += m.num_rows
|
||||
else:
|
||||
# Trouble case: split it up.
|
||||
lm, rm = _split_meta(m, left_size - acc)
|
||||
lb, rb = _split_block(b, left_size - acc, label_selector)
|
||||
left_meta.append(lm)
|
||||
right_meta.append(rm)
|
||||
left_blocks.append(lb)
|
||||
right_blocks.append(rb)
|
||||
acc += lm.num_rows
|
||||
assert acc == left_size
|
||||
left = RefBundle(
|
||||
[BlockEntry(b, m) for b, m in zip(left_blocks, left_meta)],
|
||||
owns_blocks=bundle.owns_blocks,
|
||||
schema=bundle.schema,
|
||||
)
|
||||
right = RefBundle(
|
||||
[BlockEntry(b, m) for b, m in zip(right_blocks, right_meta)],
|
||||
owns_blocks=bundle.owns_blocks,
|
||||
schema=bundle.schema,
|
||||
)
|
||||
assert left.num_rows() == left_size
|
||||
assert left.num_rows() + right.num_rows() == bundle.num_rows()
|
||||
return left, right
|
||||
|
||||
|
||||
def _split_meta(
|
||||
m: BlockMetadata, left_size: int
|
||||
) -> Tuple[BlockMetadata, BlockMetadata]:
|
||||
left_bytes = int(math.floor(m.size_bytes * (left_size / m.num_rows)))
|
||||
left = BlockMetadata(
|
||||
num_rows=left_size,
|
||||
size_bytes=left_bytes,
|
||||
input_files=m.input_files,
|
||||
exec_stats=None,
|
||||
)
|
||||
right = BlockMetadata(
|
||||
num_rows=m.num_rows - left_size,
|
||||
size_bytes=m.size_bytes - left_bytes,
|
||||
input_files=m.input_files,
|
||||
exec_stats=None,
|
||||
)
|
||||
return left, right
|
||||
|
||||
|
||||
def _split_block(
|
||||
b: ObjectRef[Block],
|
||||
left_size: int,
|
||||
label_selector: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[ObjectRef[Block], ObjectRef[Block]]:
|
||||
split_single_block = cached_remote_fn(_split_single_block)
|
||||
options: Dict[str, Any] = {"num_cpus": 0, "num_returns": 2}
|
||||
if label_selector:
|
||||
options["label_selector"] = label_selector
|
||||
left, right = split_single_block.options(**options).remote(b, left_size)
|
||||
return left, right
|
||||
|
||||
|
||||
def _split_single_block(b: Block, left_size: int) -> Tuple[Block, Block]:
|
||||
acc = BlockAccessor.for_block(b)
|
||||
left = acc.slice(0, left_size)
|
||||
right = acc.slice(left_size, acc.num_rows())
|
||||
assert BlockAccessor.for_block(left).num_rows() == left_size
|
||||
assert BlockAccessor.for_block(right).num_rows() == (acc.num_rows() - left_size)
|
||||
return left, right
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
import dataclasses
|
||||
import functools
|
||||
import logging
|
||||
import typing
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from ray.data._internal.execution.bundle_queue import (
|
||||
BaseBundleQueue,
|
||||
FIFOBundleQueue,
|
||||
)
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
BlockEntry,
|
||||
ExecutionResources,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.execution.interfaces.physical_operator import (
|
||||
MetadataOpTask,
|
||||
ObjectStoreUsage,
|
||||
OpTask,
|
||||
estimate_total_num_of_blocks,
|
||||
)
|
||||
from ray.data._internal.execution.operators.base_physical_operator import (
|
||||
InternalQueueOperatorMixin,
|
||||
)
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_tasks import (
|
||||
SHUFFLE_PEAK_MEMORY_MULTIPLIER,
|
||||
PartitionFn,
|
||||
_shuffle_map_task,
|
||||
)
|
||||
from ray.data._internal.execution.operators.sub_progress import SubProgressBarMixin
|
||||
from ray.data.block import Block, BlockMetadata, BlockStats
|
||||
from ray.data.context import DataContext
|
||||
from ray.types import ObjectRef
|
||||
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ray.data._internal.progress.base_progress import BaseProgressBar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_PARTITION_ID_SENTINEL = "__partition__"
|
||||
|
||||
|
||||
def make_partition_sentinel(partition_id: int) -> Tuple[str, ...]:
|
||||
return (f"{_PARTITION_ID_SENTINEL}{partition_id}",)
|
||||
|
||||
|
||||
def extract_partition_id(bundle: RefBundle) -> int:
|
||||
"""Recover the partition_id stamped onto an upstream bundle."""
|
||||
for entry in bundle.blocks:
|
||||
files = entry.metadata.input_files
|
||||
if not files:
|
||||
continue
|
||||
for f in files:
|
||||
if f.startswith(_PARTITION_ID_SENTINEL):
|
||||
return int(f[len(_PARTITION_ID_SENTINEL) :])
|
||||
raise ValueError("ShuffleMapOp bundle is missing a partition_id sentinel.")
|
||||
|
||||
|
||||
class ShuffleMapOp(InternalQueueOperatorMixin, PhysicalOperator, SubProgressBarMixin):
|
||||
"""Map phase of a shuffle: partition inputs and group shards by partition.
|
||||
|
||||
Each map task splits its input into num_partitions shards. Shards land in a
|
||||
per-partition staging queue as tasks finish. Once upstream is done and no
|
||||
map tasks remain, each staging queue is drained and merged into a single
|
||||
block per mapper. That yields one output bundle per partition;
|
||||
|
||||
Args:
|
||||
input_op: Upstream physical operator.
|
||||
data_context: Runtime configuration.
|
||||
num_partitions: Total number of output partitions.
|
||||
partition_fn: Function mapping a pa.Table to Dict[int, pa.Table].
|
||||
pre_map_merge_threshold: Byte threshold per node at which buffered
|
||||
blocks are merged into a single map task. Set to 0 to disable.
|
||||
map_runtime_env: Optional runtime_env for map tasks; useful to
|
||||
isolate map workers from other ops.
|
||||
map_cpus: CPU request per map task.
|
||||
name: Display name shown in progress bars and logs.
|
||||
"""
|
||||
|
||||
_DEFAULT_SHUFFLE_MAP_TASK_NUM_CPUS = 1.0
|
||||
_DEFAULT_PRE_MAP_MERGE_THRESHOLD = 1024 * 1024 * 1024 # 1 GB
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_op: PhysicalOperator,
|
||||
data_context: DataContext,
|
||||
*,
|
||||
num_partitions: int,
|
||||
partition_fn: PartitionFn,
|
||||
pre_map_merge_threshold: int = _DEFAULT_PRE_MAP_MERGE_THRESHOLD,
|
||||
map_runtime_env: Optional[Dict[str, Any]] = None,
|
||||
map_cpus: float = _DEFAULT_SHUFFLE_MAP_TASK_NUM_CPUS,
|
||||
name: str = "ShuffleMap",
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
input_dependencies=[input_op],
|
||||
data_context=data_context,
|
||||
)
|
||||
|
||||
self._num_partitions: int = num_partitions
|
||||
self._partition_fn: PartitionFn = partition_fn
|
||||
|
||||
# -- Map task config -------------------------------------------------
|
||||
self._shuffle_map_task_num_cpus: float = map_cpus
|
||||
self._map_runtime_env: Optional[Dict[str, Any]] = map_runtime_env
|
||||
|
||||
# -- Pre-map merge ---------------------------------------------------
|
||||
self._pre_map_merge_threshold: int = pre_map_merge_threshold
|
||||
self._merge_buffer_refs_by_node: Dict[
|
||||
str, List[ObjectRef[Block]]
|
||||
] = defaultdict(list)
|
||||
self._merge_buffer_bytes_by_node: Dict[str, int] = defaultdict(int)
|
||||
self._merge_buffer_bundles_by_node: Dict[str, List[RefBundle]] = defaultdict(
|
||||
list
|
||||
)
|
||||
|
||||
# -- Map task tracking -----------------------------------------------
|
||||
self._next_shuffle_map_task_idx: int = 0
|
||||
self._shuffle_map_tasks: Dict[int, MetadataOpTask] = {}
|
||||
self._map_resource_usage = ExecutionResources.zero()
|
||||
|
||||
# -- Per-partition staging queues ------------------------------------
|
||||
self._partition_staging: List[FIFOBundleQueue] = [
|
||||
FIFOBundleQueue() for _ in range(num_partitions)
|
||||
]
|
||||
|
||||
# -- Per-partition total bytes ---------------------------------------
|
||||
self._partition_bytes: Dict[int, int] = defaultdict(int)
|
||||
|
||||
# -- Output queue ---------------------------------------------------
|
||||
self._output_queue: FIFOBundleQueue = FIFOBundleQueue()
|
||||
self._partition_bundles_emitted: bool = False
|
||||
|
||||
# -- Stats -----------------------------------------------------------
|
||||
self._total_input_rows: int = 0
|
||||
self._total_input_bytes: int = 0
|
||||
self._map_blocks_stats: List[BlockStats] = []
|
||||
|
||||
# -- Sub-progress bars -----------------------------------------------
|
||||
self._map_bar: Optional["BaseProgressBar"] = None
|
||||
|
||||
@property
|
||||
def _input_queues(self) -> List[BaseBundleQueue]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def _output_queues(self) -> List[BaseBundleQueue]:
|
||||
return [*self._partition_staging, self._output_queue]
|
||||
|
||||
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
||||
assert input_index == 0
|
||||
|
||||
if self._pre_map_merge_threshold > 0:
|
||||
preferred_locs = refs.get_preferred_object_locations()
|
||||
node_id = (
|
||||
max(preferred_locs, key=lambda n: preferred_locs[n])
|
||||
if preferred_locs
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
for block_ref, block_metadata in zip(refs.block_refs, refs.metadata):
|
||||
self._merge_buffer_refs_by_node[node_id].append(block_ref)
|
||||
self._merge_buffer_bytes_by_node[node_id] += (
|
||||
block_metadata.size_bytes or 0
|
||||
)
|
||||
self._merge_buffer_bundles_by_node[node_id].append(refs)
|
||||
|
||||
if (
|
||||
self._merge_buffer_bytes_by_node[node_id]
|
||||
>= self._pre_map_merge_threshold
|
||||
):
|
||||
self._flush_merge_buffer(node_id)
|
||||
else:
|
||||
self._submit_shuffle_map_task(
|
||||
list(refs.block_refs),
|
||||
[refs],
|
||||
estimated_bytes=sum((m.size_bytes or 0) for m in refs.metadata),
|
||||
)
|
||||
|
||||
def all_inputs_done(self) -> None:
|
||||
super().all_inputs_done()
|
||||
for node_id in list(self._merge_buffer_refs_by_node.keys()):
|
||||
self._flush_merge_buffer(node_id)
|
||||
self._maybe_emit_partition_bundles()
|
||||
|
||||
def _flush_merge_buffer(self, node_id: str) -> None:
|
||||
block_refs = self._merge_buffer_refs_by_node.pop(node_id, [])
|
||||
bundles = self._merge_buffer_bundles_by_node.pop(node_id, [])
|
||||
estimated_bytes = self._merge_buffer_bytes_by_node.pop(node_id, 0)
|
||||
if not block_refs:
|
||||
for bundle in bundles:
|
||||
bundle.destroy_if_owned()
|
||||
return
|
||||
self._submit_shuffle_map_task(
|
||||
block_refs,
|
||||
bundles,
|
||||
estimated_bytes=estimated_bytes,
|
||||
target_node_id=node_id if node_id != "unknown" else None,
|
||||
)
|
||||
|
||||
def _submit_shuffle_map_task(
|
||||
self,
|
||||
block_refs: List[ObjectRef[Block]],
|
||||
input_bundles: List[RefBundle],
|
||||
estimated_bytes: int = 0,
|
||||
target_node_id: Optional[str] = None,
|
||||
) -> None:
|
||||
cur_task_idx = self._next_shuffle_map_task_idx
|
||||
self._next_shuffle_map_task_idx += 1
|
||||
|
||||
resources: Dict[str, Any] = {"num_cpus": self._shuffle_map_task_num_cpus}
|
||||
if estimated_bytes > 0:
|
||||
resources["memory"] = estimated_bytes * SHUFFLE_PEAK_MEMORY_MULTIPLIER
|
||||
|
||||
ray_options: Dict[str, Any] = {
|
||||
**resources,
|
||||
"num_returns": self._num_partitions + 1,
|
||||
}
|
||||
if target_node_id is not None:
|
||||
ray_options["scheduling_strategy"] = NodeAffinitySchedulingStrategy(
|
||||
target_node_id, soft=True
|
||||
)
|
||||
if self._map_runtime_env is not None:
|
||||
ray_options["runtime_env"] = self._map_runtime_env
|
||||
|
||||
map_refs = _shuffle_map_task.options(**ray_options).remote(
|
||||
*block_refs,
|
||||
partition_fn=self._partition_fn,
|
||||
num_partitions=self._num_partitions,
|
||||
compression=self.data_context.hash_shuffle_compression,
|
||||
)
|
||||
metadata_ref = map_refs[0]
|
||||
partition_refs = list(map_refs[1:])
|
||||
|
||||
task = MetadataOpTask(
|
||||
task_index=cur_task_idx,
|
||||
object_ref=metadata_ref,
|
||||
task_done_callback=functools.partial(
|
||||
self._handle_map_done, cur_task_idx, partition_refs, input_bundles
|
||||
),
|
||||
task_resource_bundle=ExecutionResources.from_resource_dict(resources),
|
||||
)
|
||||
self._shuffle_map_tasks[cur_task_idx] = task
|
||||
requested = task.get_requested_resource_bundle()
|
||||
assert requested is not None
|
||||
self._map_resource_usage = self._map_resource_usage.add(requested)
|
||||
|
||||
all_blocks_meta = tuple(
|
||||
BlockEntry(ref=ref, metadata=meta)
|
||||
for bundle in input_bundles
|
||||
for ref, meta in zip(bundle.block_refs, bundle.metadata)
|
||||
)
|
||||
self._metrics.on_task_submitted(
|
||||
cur_task_idx,
|
||||
RefBundle(all_blocks_meta, schema=None, owns_blocks=False),
|
||||
task_id=task.get_task_id(),
|
||||
)
|
||||
|
||||
if self._map_bar is not None:
|
||||
_, _, num_rows = estimate_total_num_of_blocks(
|
||||
cur_task_idx + 1,
|
||||
self.upstream_op_num_outputs(),
|
||||
self._metrics,
|
||||
total_num_tasks=None,
|
||||
)
|
||||
self._map_bar.update(total=num_rows)
|
||||
|
||||
def _handle_map_done(
|
||||
self,
|
||||
task_idx: int,
|
||||
partition_refs: List[ObjectRef[Block]],
|
||||
input_bundles: List[RefBundle],
|
||||
) -> None:
|
||||
task = self._shuffle_map_tasks.pop(task_idx)
|
||||
requested = task.get_requested_resource_bundle()
|
||||
assert requested is not None
|
||||
self._map_resource_usage = self._map_resource_usage.subtract(requested)
|
||||
|
||||
# `task_done_callback` fires only after the metadata ref is ready,
|
||||
# so this is just local deserialization.
|
||||
input_meta, shard_sizes, output_schema = ray.get(task.get_waitable())
|
||||
|
||||
for partition_id, ref in enumerate(partition_refs):
|
||||
rows, nbytes = shard_sizes.get(partition_id, (0, 0))
|
||||
shard_meta = BlockMetadata(
|
||||
num_rows=rows,
|
||||
size_bytes=nbytes,
|
||||
exec_stats=None,
|
||||
input_files=None,
|
||||
)
|
||||
shard_bundle = RefBundle(
|
||||
(BlockEntry(ref=ref, metadata=shard_meta),),
|
||||
schema=output_schema,
|
||||
owns_blocks=True,
|
||||
)
|
||||
self._partition_staging[partition_id].add(shard_bundle)
|
||||
self._partition_bytes[partition_id] += nbytes
|
||||
|
||||
for bundle in input_bundles:
|
||||
bundle.destroy_if_owned()
|
||||
|
||||
self._total_input_rows += input_meta.num_rows or 0
|
||||
self._total_input_bytes += input_meta.size_bytes or 0
|
||||
self._map_blocks_stats.append(input_meta.to_stats())
|
||||
|
||||
self._metrics.on_task_finished(
|
||||
task_idx,
|
||||
None,
|
||||
task_exec_stats=None,
|
||||
task_exec_driver_stats=None,
|
||||
)
|
||||
|
||||
if self._map_bar is not None:
|
||||
self._map_bar.update(increment=input_meta.num_rows or 0)
|
||||
|
||||
self._maybe_emit_partition_bundles()
|
||||
|
||||
def _maybe_emit_partition_bundles(self) -> None:
|
||||
"""Drain each partition's staging queue into one output bundle.
|
||||
|
||||
Every partition is staged (empty partitions carry a schema-only shard),
|
||||
so this emits exactly num_partitions bundles.
|
||||
"""
|
||||
if self._partition_bundles_emitted:
|
||||
return
|
||||
if self._shuffle_map_tasks or self._merge_buffer_refs_by_node:
|
||||
return
|
||||
if not self._inputs_complete:
|
||||
return
|
||||
|
||||
self._partition_bundles_emitted = True
|
||||
|
||||
for partition_id in range(self._num_partitions):
|
||||
staging = self._partition_staging[partition_id]
|
||||
if not staging.has_next():
|
||||
continue
|
||||
shards: List[RefBundle] = []
|
||||
while staging.has_next():
|
||||
shards.append(staging.get_next())
|
||||
|
||||
merged = RefBundle.merge_ref_bundles(shards)
|
||||
# Stamp the partition_id sentinel onto the merged bundle's
|
||||
# first block so the downstream reducer can recover the
|
||||
# partition this bundle represents.
|
||||
stamped_blocks = []
|
||||
for i, entry in enumerate(merged.blocks):
|
||||
meta = entry.metadata
|
||||
if i == 0:
|
||||
meta = dataclasses.replace(
|
||||
meta, input_files=make_partition_sentinel(partition_id)
|
||||
)
|
||||
stamped_blocks.append(BlockEntry(ref=entry.ref, metadata=meta))
|
||||
stamped = RefBundle(
|
||||
tuple(stamped_blocks),
|
||||
schema=merged.schema,
|
||||
owns_blocks=merged.owns_blocks,
|
||||
)
|
||||
self._output_queue.add(stamped)
|
||||
self._metrics.on_output_queued(stamped)
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return self._output_queue.has_next()
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
bundle: RefBundle = self._output_queue.get_next()
|
||||
self._metrics.on_output_dequeued(bundle)
|
||||
return bundle
|
||||
|
||||
def get_partition_bytes(self) -> Dict[int, int]:
|
||||
return dict(self._partition_bytes)
|
||||
|
||||
def get_active_tasks(self) -> List[OpTask]:
|
||||
return list(self._shuffle_map_tasks.values())
|
||||
|
||||
def has_execution_finished(self) -> bool:
|
||||
if (
|
||||
self._shuffle_map_tasks
|
||||
or self._merge_buffer_refs_by_node
|
||||
or not self._partition_bundles_emitted
|
||||
or self._output_queue.has_next()
|
||||
):
|
||||
return False
|
||||
return super().has_execution_finished()
|
||||
|
||||
def has_completed(self) -> bool:
|
||||
return (
|
||||
not self._shuffle_map_tasks
|
||||
and not self._merge_buffer_refs_by_node
|
||||
and self._partition_bundles_emitted
|
||||
and not self._output_queue.has_next()
|
||||
and super().has_completed()
|
||||
)
|
||||
|
||||
def _do_shutdown(self, force: bool = False) -> None:
|
||||
super()._do_shutdown(force)
|
||||
self._shuffle_map_tasks.clear()
|
||||
self._merge_buffer_refs_by_node.clear()
|
||||
for bundles in self._merge_buffer_bundles_by_node.values():
|
||||
for bundle in bundles:
|
||||
bundle.destroy_if_owned()
|
||||
self._merge_buffer_bundles_by_node.clear()
|
||||
self._merge_buffer_bytes_by_node.clear()
|
||||
for queue in self._partition_staging:
|
||||
queue.clear()
|
||||
self._output_queue.clear()
|
||||
|
||||
def get_stats(self) -> Dict[str, List[BlockStats]]:
|
||||
return {self._name: self._map_blocks_stats}
|
||||
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
return self._total_input_rows if self._total_input_rows > 0 else None
|
||||
|
||||
def current_logical_usage(self) -> ExecutionResources:
|
||||
return ExecutionResources(
|
||||
cpu=self._map_resource_usage.cpu,
|
||||
memory=self._map_resource_usage.memory,
|
||||
)
|
||||
|
||||
def estimate_object_store_usage(self, state) -> ObjectStoreUsage:
|
||||
return ObjectStoreUsage(internal=0, outputs=0)
|
||||
|
||||
def incremental_resource_usage(self) -> ExecutionResources:
|
||||
avg_input = self._metrics.average_bytes_inputs_per_task
|
||||
memory = int(avg_input * SHUFFLE_PEAK_MEMORY_MULTIPLIER) if avg_input else 0
|
||||
return ExecutionResources(
|
||||
cpu=self._shuffle_map_task_num_cpus,
|
||||
memory=memory,
|
||||
)
|
||||
|
||||
def min_scheduling_resources(self) -> ExecutionResources:
|
||||
return self.incremental_resource_usage()
|
||||
|
||||
def progress_str(self) -> str:
|
||||
maps_done = self._next_shuffle_map_task_idx - len(self._shuffle_map_tasks)
|
||||
parts = [f"map: {maps_done}/{self._next_shuffle_map_task_idx}"]
|
||||
total_merge_buf = sum(
|
||||
len(refs) for refs in self._merge_buffer_refs_by_node.values()
|
||||
)
|
||||
if total_merge_buf:
|
||||
parts.append(f"merge_buf: {total_merge_buf}")
|
||||
return ", ".join(parts)
|
||||
|
||||
def get_sub_progress_bar_names(self) -> Optional[List[str]]:
|
||||
return ["Map"]
|
||||
|
||||
def set_sub_progress_bar(self, name: str, pg: "BaseProgressBar") -> None:
|
||||
if name == "Map":
|
||||
self._map_bar = pg
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
import functools
|
||||
import logging
|
||||
import typing
|
||||
from collections import deque
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
import ray
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
BlockEntry,
|
||||
ExecutionResources,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
TaskContext,
|
||||
)
|
||||
from ray.data._internal.execution.interfaces.physical_operator import (
|
||||
DataOpTask,
|
||||
OpTask,
|
||||
TaskExecDriverStats,
|
||||
estimate_total_num_of_blocks,
|
||||
)
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_map_operator import (
|
||||
ShuffleMapOp,
|
||||
extract_partition_id,
|
||||
)
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_tasks import (
|
||||
SHUFFLE_PEAK_MEMORY_MULTIPLIER,
|
||||
ReduceFn,
|
||||
_shuffle_reduce_task,
|
||||
)
|
||||
from ray.data._internal.execution.operators.sub_progress import SubProgressBarMixin
|
||||
from ray.data.block import BlockAccessor, BlockStats, TaskExecWorkerStats, to_stats
|
||||
from ray.data.context import DataContext
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ray.data._internal.execution.operators.map_transformer import MapTransformer
|
||||
from ray.data._internal.progress.base_progress import BaseProgressBar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShuffleReduceOp(PhysicalOperator, SubProgressBarMixin):
|
||||
"""Reduce phase of a shuffle.
|
||||
|
||||
Supports one or more co-partitioned upstream `ShuffleMapOp`s. With a single
|
||||
input this is the unary reduce used by repartition/sort. With multiple
|
||||
inputs (e.g. join) every input must be partitioned into the same
|
||||
`num_partitions`; this op pairs up the per-partition bundles across all
|
||||
inputs and hands the reducer one shard list per input.
|
||||
|
||||
Args:
|
||||
input_op: Upstream `ShuffleMapOp`, or a list of them (one per input).
|
||||
For an N-input reduce the reducer receives shards in this order.
|
||||
data_context: Runtime configuration.
|
||||
num_partitions: Total number of output partitions. Must match the
|
||||
value used by every paired `ShuffleMapOp`.
|
||||
reduce_fn: Function called once per partition with all shards to
|
||||
combine them into output blocks. Receives
|
||||
`(partition_id, tables_by_input)` where `tables_by_input` is
|
||||
aligned with `input_op`.
|
||||
disallow_block_splitting: If True, output blocks are emitted as-is
|
||||
without being reshaped to `target_max_block_size`.
|
||||
reduce_ray_remote_args: Remote args for the reducer tasks.
|
||||
name: Display name shown in progress bars and logs.
|
||||
fused_output_map_transformer: Set by ``FuseOperators`` when a
|
||||
``TaskPoolMapOperator`` directly downstream is fused into this
|
||||
reduce: each reduce task applies it to its output blocks before
|
||||
yielding.
|
||||
fused_output_map_task_kwargs: Per-task kwargs the fused map injects into
|
||||
its ``TaskContext``.
|
||||
fused_output_map_target_max_block_size_override: The fused map op's
|
||||
block-size override.
|
||||
"""
|
||||
|
||||
_DEFAULT_SHUFFLE_REDUCE_TASK_NUM_CPUS = 1.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_op: Union[ShuffleMapOp, List[ShuffleMapOp]],
|
||||
data_context: DataContext,
|
||||
*,
|
||||
num_partitions: int,
|
||||
reduce_fn: ReduceFn,
|
||||
disallow_block_splitting: bool = False,
|
||||
reduce_ray_remote_args: Optional[Dict[str, Any]] = None,
|
||||
name: str = "ShuffleReduce",
|
||||
fused_output_map_transformer: Optional["MapTransformer"] = None,
|
||||
fused_output_map_task_kwargs: Optional[Dict[str, Any]] = None,
|
||||
fused_output_map_target_max_block_size_override: Optional[int] = None,
|
||||
):
|
||||
input_ops: List[PhysicalOperator] = (
|
||||
[input_op] if isinstance(input_op, ShuffleMapOp) else list(input_op)
|
||||
)
|
||||
assert input_ops, "ShuffleReduceOp requires at least one upstream ShuffleMapOp"
|
||||
super().__init__(
|
||||
name=name,
|
||||
input_dependencies=input_ops,
|
||||
data_context=data_context,
|
||||
)
|
||||
|
||||
self._num_inputs: int = len(input_ops)
|
||||
self._num_partitions: int = num_partitions
|
||||
self._reduce_fn: ReduceFn = reduce_fn
|
||||
self._disallow_block_splitting: bool = disallow_block_splitting
|
||||
|
||||
# -- Reduce task config & tracking -----------------------------------
|
||||
self._reduce_ray_remote_args: Dict[str, Any] = dict(
|
||||
reduce_ray_remote_args or {}
|
||||
)
|
||||
self._shuffle_reduce_tasks: Dict[int, DataOpTask] = {}
|
||||
self._num_reduce_tasks_submitted: int = 0
|
||||
|
||||
# -- Per-partition pairing across inputs -----------------------------
|
||||
# partition_id -> input_index -> the single bundle that input emitted
|
||||
# for that partition. A reduce task is submitted once all inputs have
|
||||
# delivered their bundle for a partition. With a single input a bundle
|
||||
# pairs immediately.
|
||||
self._pending_inputs: Dict[int, Dict[int, RefBundle]] = {}
|
||||
|
||||
# -- Fused downstream map --------------------------------------------
|
||||
self._fused_output_map_transformer = fused_output_map_transformer
|
||||
self._fused_output_map_task_kwargs = fused_output_map_task_kwargs or {}
|
||||
self._fused_output_map_target_max_block_size_override = (
|
||||
fused_output_map_target_max_block_size_override
|
||||
)
|
||||
|
||||
# -- Output queue ----------------------------------------------------
|
||||
self._output_queue: deque = deque()
|
||||
|
||||
# -- Stats -----------------------------------------------------------
|
||||
self._output_blocks_stats: List[BlockStats] = []
|
||||
|
||||
# -- Sub-progress bars -----------------------------------------------
|
||||
self._reduce_bar: Optional["BaseProgressBar"] = None
|
||||
|
||||
def _reduce_task_remote_args(self, memory_estimate: int) -> Dict[str, Any]:
|
||||
remote_args: Dict[str, Any] = {
|
||||
"num_cpus": self._DEFAULT_SHUFFLE_REDUCE_TASK_NUM_CPUS,
|
||||
"scheduling_strategy": "SPREAD",
|
||||
}
|
||||
if memory_estimate > 0:
|
||||
remote_args["memory"] = memory_estimate
|
||||
remote_args.update(self._reduce_ray_remote_args)
|
||||
remote_args["num_returns"] = "streaming"
|
||||
return remote_args
|
||||
|
||||
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
||||
"""Buffer this input's partition-bundle; submit when all inputs paired.
|
||||
|
||||
Each upstream bundle is a single partition's shards (M blocks from M
|
||||
mappers) from one input. The partition_id is encoded in the first
|
||||
block's `input_files`. A reduce task runs only once every input has
|
||||
delivered its bundle for that partition (immediately for the common
|
||||
single-input case), so the reducer sees all inputs' shards together.
|
||||
This is the framework-gated entry point — the executor only calls it
|
||||
when all configured backpressure policies say the op can accept another
|
||||
input.
|
||||
"""
|
||||
assert 0 <= input_index < self._num_inputs
|
||||
|
||||
if not refs.block_refs:
|
||||
refs.destroy_if_owned()
|
||||
return
|
||||
|
||||
partition_id = extract_partition_id(refs)
|
||||
|
||||
# Single-input empty-partition fast path: emit one empty block instead
|
||||
# of launching a reduce task. Skipped for multi-input reduces (an outer
|
||||
# join's empty side can still produce rows) and when a downstream map is
|
||||
# fused in (the map must run even on empty partitions, e.g. a Write).
|
||||
schema = refs.schema
|
||||
if (
|
||||
self._num_inputs == 1
|
||||
and self._fused_output_map_transformer is None
|
||||
and isinstance(schema, pa.Schema)
|
||||
and not any((m.num_rows or 0) for m in refs.metadata)
|
||||
):
|
||||
self._emit_empty_partition(refs, schema)
|
||||
return
|
||||
|
||||
pending = self._pending_inputs.setdefault(partition_id, {})
|
||||
assert input_index not in pending, (
|
||||
f"input {input_index} already delivered a bundle for partition "
|
||||
f"{partition_id}; each ShuffleMapOp must emit at most one bundle "
|
||||
f"per partition"
|
||||
)
|
||||
pending[input_index] = refs
|
||||
if len(pending) == self._num_inputs:
|
||||
del self._pending_inputs[partition_id]
|
||||
self._submit_reduce_task(
|
||||
partition_id, [pending[i] for i in range(self._num_inputs)]
|
||||
)
|
||||
|
||||
def all_inputs_done(self) -> None:
|
||||
super().all_inputs_done()
|
||||
# Every upstream input is now exhausted. A partition still missing an
|
||||
# input's bundle will never receive it -- that input ran no map tasks for
|
||||
# this partition's key space (e.g. a block-less input). Flush such
|
||||
# partitions with an empty placeholder for each missing input so the op
|
||||
# can complete instead of hanging on a never-paired partition.
|
||||
for partition_id in list(self._pending_inputs.keys()):
|
||||
pending = self._pending_inputs.pop(partition_id)
|
||||
bundles = [
|
||||
pending.get(i) or RefBundle((), schema=None, owns_blocks=True)
|
||||
for i in range(self._num_inputs)
|
||||
]
|
||||
self._submit_reduce_task(partition_id, bundles)
|
||||
|
||||
def _submit_reduce_task(self, partition_id: int, bundles: List[RefBundle]) -> None:
|
||||
"""Submit one reduce task for a fully-paired partition."""
|
||||
shard_refs_by_input = []
|
||||
metrics_blocks = []
|
||||
estimated_bytes = 0
|
||||
for bundle in bundles:
|
||||
shard_refs_by_input.append(list(bundle.block_refs))
|
||||
metrics_blocks.extend(bundle.blocks)
|
||||
estimated_bytes += sum((m.size_bytes or 0) for m in bundle.metadata)
|
||||
|
||||
reduce_options = self._reduce_task_remote_args(
|
||||
int(estimated_bytes * SHUFFLE_PEAK_MEMORY_MULTIPLIER)
|
||||
if estimated_bytes > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
target_max_block_size = (
|
||||
None
|
||||
if self._disallow_block_splitting
|
||||
else self.data_context.target_max_block_size
|
||||
)
|
||||
|
||||
map_task_context = None
|
||||
if self._fused_output_map_transformer is not None:
|
||||
map_task_context = TaskContext(
|
||||
task_idx=partition_id,
|
||||
op_name=self.name,
|
||||
target_max_block_size_override=(
|
||||
self._fused_output_map_target_max_block_size_override
|
||||
),
|
||||
)
|
||||
map_task_context.kwargs.update(self._fused_output_map_task_kwargs)
|
||||
|
||||
block_gen = _shuffle_reduce_task.options(**reduce_options).remote(
|
||||
shard_refs_by_input, # pyrefly: ignore[bad-argument-type]
|
||||
partition_id,
|
||||
self._reduce_fn,
|
||||
target_max_block_size,
|
||||
self.data_context.hash_shuffle_reduce_batch_size,
|
||||
self.data_context.hash_shuffle_reduce_get_timeout_s,
|
||||
self._fused_output_map_transformer,
|
||||
map_task_context,
|
||||
self.data_context,
|
||||
)
|
||||
metrics_bundle = RefBundle(
|
||||
tuple(metrics_blocks), schema=None, owns_blocks=False
|
||||
)
|
||||
|
||||
data_task = DataOpTask(
|
||||
task_index=partition_id,
|
||||
streaming_gen=block_gen,
|
||||
block_ref_counter=self._block_ref_counter,
|
||||
producer_id=self.id,
|
||||
output_ready_callback=functools.partial(
|
||||
self._handle_reduce_output_ready, partition_id
|
||||
),
|
||||
task_done_callback=functools.partial(
|
||||
self._handle_reduce_done, partition_id, bundles
|
||||
),
|
||||
task_resource_bundle=ExecutionResources.from_resource_dict(reduce_options),
|
||||
operator_name=self.name,
|
||||
)
|
||||
|
||||
assert partition_id not in self._shuffle_reduce_tasks, (
|
||||
f"partition_id {partition_id} already has an in-flight reducer "
|
||||
f"task; ShuffleMapOp must emit at most one bundle per partition"
|
||||
)
|
||||
self._shuffle_reduce_tasks[partition_id] = data_task
|
||||
self._num_reduce_tasks_submitted += 1
|
||||
self._metrics.on_task_submitted(
|
||||
partition_id, metrics_bundle, task_id=data_task.get_task_id()
|
||||
)
|
||||
|
||||
def _emit_empty_partition(self, refs: RefBundle, schema: pa.Schema) -> None:
|
||||
"""Emit one empty output block for an empty partition.
|
||||
|
||||
The partition contributed no rows, so there is nothing to reduce; we
|
||||
build the empty block from the schema the map stage propagated onto
|
||||
the bundle and queue it as this partition's single output block.
|
||||
"""
|
||||
empty_block = schema.empty_table()
|
||||
block_meta = BlockAccessor.for_block(empty_block).get_metadata()
|
||||
out_bundle = RefBundle(
|
||||
(
|
||||
BlockEntry(
|
||||
ref=ray.put(empty_block), # pyrefly: ignore[bad-argument-type]
|
||||
metadata=block_meta,
|
||||
),
|
||||
),
|
||||
schema=schema,
|
||||
owns_blocks=True,
|
||||
)
|
||||
refs.destroy_if_owned()
|
||||
|
||||
# Empty partition creates a new block; register it for memory tracking.
|
||||
self._block_ref_counter.on_block_produced(
|
||||
out_bundle.blocks[0].ref, # pyrefly: ignore[bad-argument-type]
|
||||
block_meta.size_bytes or 0,
|
||||
self.id,
|
||||
)
|
||||
self._num_reduce_tasks_submitted += 1
|
||||
self._output_queue.append(out_bundle)
|
||||
self._metrics.on_output_queued(out_bundle)
|
||||
_, num_outputs, num_rows = estimate_total_num_of_blocks(
|
||||
self._num_reduce_tasks_submitted,
|
||||
self.upstream_op_num_outputs(),
|
||||
self._metrics,
|
||||
total_num_tasks=self._num_partitions,
|
||||
)
|
||||
self._estimated_num_output_bundles = num_outputs
|
||||
self._estimated_output_num_rows = num_rows
|
||||
if self._reduce_bar is not None:
|
||||
self._reduce_bar.update(increment=0, total=self.num_output_rows_total())
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return len(self._output_queue) > 0
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
bundle: RefBundle = self._output_queue.popleft()
|
||||
self._metrics.on_output_dequeued(bundle)
|
||||
self._output_blocks_stats.extend(to_stats(bundle.metadata))
|
||||
return bundle
|
||||
|
||||
def get_active_tasks(self) -> List[OpTask]:
|
||||
return list(self._shuffle_reduce_tasks.values())
|
||||
|
||||
def _handle_reduce_output_ready(self, partition_id: int, bundle: RefBundle) -> None:
|
||||
self._output_queue.append(bundle)
|
||||
self._metrics.on_output_queued(bundle)
|
||||
self._metrics.on_task_output_generated(task_index=partition_id, output=bundle)
|
||||
_, num_outputs, num_rows = estimate_total_num_of_blocks(
|
||||
self._num_reduce_tasks_submitted,
|
||||
self.upstream_op_num_outputs(),
|
||||
self._metrics,
|
||||
total_num_tasks=self._num_partitions,
|
||||
)
|
||||
self._estimated_num_output_bundles = num_outputs
|
||||
self._estimated_output_num_rows = num_rows
|
||||
if self._reduce_bar is not None:
|
||||
self._reduce_bar.update(
|
||||
increment=bundle.num_rows() or 0,
|
||||
total=self.num_output_rows_total(),
|
||||
)
|
||||
|
||||
def _handle_reduce_done(
|
||||
self,
|
||||
partition_id: int,
|
||||
input_bundles: List[RefBundle],
|
||||
exc: Optional[Exception],
|
||||
task_exec_stats: Optional[TaskExecWorkerStats],
|
||||
task_exec_driver_stats: Optional[TaskExecDriverStats],
|
||||
) -> None:
|
||||
"""Callback when a reduce task finishes (with or without exception)."""
|
||||
for input_bundle in input_bundles:
|
||||
input_bundle.destroy_if_owned()
|
||||
if partition_id not in self._shuffle_reduce_tasks:
|
||||
return
|
||||
self._shuffle_reduce_tasks.pop(partition_id)
|
||||
self._metrics.on_task_finished(
|
||||
task_index=partition_id,
|
||||
exception=exc,
|
||||
task_exec_stats=task_exec_stats,
|
||||
task_exec_driver_stats=task_exec_driver_stats,
|
||||
)
|
||||
if exc:
|
||||
logger.error(
|
||||
f"Reduce of partition {partition_id} failed: {exc}", exc_info=exc
|
||||
)
|
||||
|
||||
def has_execution_finished(self) -> bool:
|
||||
if self._shuffle_reduce_tasks or self._output_queue or self._pending_inputs:
|
||||
return False
|
||||
return super().has_execution_finished()
|
||||
|
||||
def has_completed(self) -> bool:
|
||||
return (
|
||||
not self._shuffle_reduce_tasks
|
||||
and not self._output_queue
|
||||
and not self._pending_inputs
|
||||
and super().has_completed()
|
||||
)
|
||||
|
||||
def _do_shutdown(self, force: bool = False) -> None:
|
||||
super()._do_shutdown(force)
|
||||
self._shuffle_reduce_tasks.clear()
|
||||
self._output_queue.clear()
|
||||
for pending in self._pending_inputs.values():
|
||||
for bundle in pending.values():
|
||||
bundle.destroy_if_owned()
|
||||
self._pending_inputs.clear()
|
||||
|
||||
def get_stats(self) -> Dict[str, List[BlockStats]]:
|
||||
return {self._name: self._output_blocks_stats}
|
||||
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
# Multi-input reduces (e.g. join) can grow or shrink the row count, so it
|
||||
# is unknown until the reducers run; a single-input reduce preserves it.
|
||||
if self._num_inputs > 1:
|
||||
return None
|
||||
upstream = self.input_dependencies[0]
|
||||
assert isinstance(upstream, ShuffleMapOp)
|
||||
return upstream.num_output_rows_total()
|
||||
|
||||
def current_logical_usage(self) -> ExecutionResources:
|
||||
usage = ExecutionResources.zero()
|
||||
for task in self._shuffle_reduce_tasks.values():
|
||||
bundle = task.get_requested_resource_bundle()
|
||||
if bundle is None:
|
||||
continue
|
||||
usage = usage.add(ExecutionResources(cpu=bundle.cpu, memory=bundle.memory))
|
||||
return usage
|
||||
|
||||
def incremental_resource_usage(self) -> ExecutionResources:
|
||||
"""Per-task resource ask for the framework's budget allocator."""
|
||||
memory = 0
|
||||
for upstream in self.input_dependencies:
|
||||
assert isinstance(upstream, ShuffleMapOp)
|
||||
sizes = [b for b in upstream.get_partition_bytes().values() if b > 0]
|
||||
if sizes:
|
||||
avg_bytes = sum(sizes) / len(sizes)
|
||||
memory += int(avg_bytes * SHUFFLE_PEAK_MEMORY_MULTIPLIER)
|
||||
return ExecutionResources.from_resource_dict(
|
||||
self._reduce_task_remote_args(memory)
|
||||
)
|
||||
|
||||
def min_scheduling_resources(self) -> ExecutionResources:
|
||||
return self.incremental_resource_usage()
|
||||
|
||||
def progress_str(self) -> str:
|
||||
submitted = self._num_reduce_tasks_submitted
|
||||
done = submitted - len(self._shuffle_reduce_tasks)
|
||||
return f"reduce: {done}/{submitted}"
|
||||
|
||||
def get_sub_progress_bar_names(self) -> Optional[List[str]]:
|
||||
return ["Reduce"]
|
||||
|
||||
def set_sub_progress_bar(self, name: str, pg: "BaseProgressBar") -> None:
|
||||
if name == "Reduce":
|
||||
self._reduce_bar = pg
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Shared remote tasks + helpers for ShuffleMapOp / ShuffleReduceOp."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import typing
|
||||
from dataclasses import replace
|
||||
from typing import Callable, Dict, Generator, Iterable, List, Optional, Tuple, Union
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
import ray
|
||||
from ray import ObjectRef
|
||||
from ray.data._internal.execution.interfaces.task_context import TaskContext
|
||||
from ray.data._internal.execution.util import yield_block_with_stats
|
||||
from ray.data._internal.output_buffer import BlockOutputBuffer, OutputBlockSizeOption
|
||||
from ray.data._internal.table_block import TableBlockAccessor
|
||||
from ray.data.block import (
|
||||
Block,
|
||||
BlockAccessor,
|
||||
BlockExecStats,
|
||||
BlockMetadata,
|
||||
BlockMetadataWithSchema,
|
||||
BlockType,
|
||||
TaskExecWorkerStats,
|
||||
)
|
||||
from ray.data.context import DataContext
|
||||
from ray.exceptions import GetTimeoutError
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ray.data._internal.execution.operators.map_transformer import MapTransformer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PartitionFn = Callable[[pa.Table], Dict[int, pa.Table]]
|
||||
ReduceFn = Callable[[int, List[List[pa.Table]]], Iterable[Block]]
|
||||
|
||||
# Peak working-set of a shuffle map/reduce task is ~2x the input bytes
|
||||
SHUFFLE_PEAK_MEMORY_MULTIPLIER = 2
|
||||
|
||||
|
||||
def _ipc_write_options(compression: Optional[str]) -> pa.ipc.IpcWriteOptions:
|
||||
"""Arrow IPC write options for the given shard compression codec.
|
||||
|
||||
Args:
|
||||
compression: A pyarrow codec name such as "lz4" or "zstd", or "none"
|
||||
(or None) to write shards uncompressed. See pyarrow.Codec for the
|
||||
full list of supported codecs:
|
||||
https://arrow.apache.org/docs/python/generated/pyarrow.Codec.html
|
||||
|
||||
Returns:
|
||||
IpcWriteOptions for encoding shards; no compression for "none"/None.
|
||||
"""
|
||||
if not compression or compression == "none":
|
||||
return pa.ipc.IpcWriteOptions()
|
||||
return pa.ipc.IpcWriteOptions(compression=pa.Codec(compression))
|
||||
|
||||
|
||||
def _partition_blocks_to_shards(
|
||||
blocks: Tuple[Block, ...], partition_fn: PartitionFn
|
||||
) -> Dict[int, List[pa.Table]]:
|
||||
"""Partition each block independently, grouping shards by partition id."""
|
||||
partition_accumulators: Dict[int, List[pa.Table]] = {}
|
||||
for block in blocks:
|
||||
block = TableBlockAccessor.try_convert_block_type(
|
||||
block, block_type=BlockType.ARROW
|
||||
)
|
||||
if block.num_rows == 0:
|
||||
continue
|
||||
assert isinstance(block, pa.Table), f"Expected pa.Table, got {type(block)}"
|
||||
if any(col.num_chunks > 1 for col in block.columns):
|
||||
block = block.combine_chunks()
|
||||
block_partitions = partition_fn(block)
|
||||
for partition_id, shard in block_partitions.items():
|
||||
if shard.num_rows > 0:
|
||||
partition_accumulators.setdefault(partition_id, []).append(shard)
|
||||
del block, block_partitions
|
||||
return partition_accumulators
|
||||
|
||||
|
||||
def _encode_partition_ipc(
|
||||
table: pa.Table,
|
||||
ipc_write_options: pa.ipc.IpcWriteOptions,
|
||||
) -> pa.Buffer:
|
||||
"""Encode one partition's shard as a single Arrow IPC stream."""
|
||||
if table.num_columns > 0:
|
||||
table = table.combine_chunks()
|
||||
|
||||
sink = pa.BufferOutputStream()
|
||||
with pa.ipc.new_stream(sink, table.schema, options=ipc_write_options) as writer:
|
||||
for batch in table.to_batches():
|
||||
writer.write_batch(batch)
|
||||
return sink.getvalue()
|
||||
|
||||
|
||||
@ray.remote # pyrefly: ignore[no-matching-overload]
|
||||
def _shuffle_map_task(
|
||||
*blocks: Block,
|
||||
partition_fn: PartitionFn,
|
||||
num_partitions: int,
|
||||
compression: Optional[str],
|
||||
) -> Tuple[
|
||||
Union[Tuple[BlockMetadata, Dict[int, Tuple[int, int]], "pa.Schema"], pa.Buffer],
|
||||
...,
|
||||
]:
|
||||
"""Map stage: partition the input blocks and return one shard per partition."""
|
||||
stats = BlockExecStats.builder()
|
||||
|
||||
# Use BlockAccessor so we also work for non-Arrow blocks (pandas, numpy)
|
||||
accessors = [BlockAccessor.for_block(b) for b in blocks]
|
||||
total_rows = sum(a.num_rows() for a in accessors)
|
||||
total_bytes = sum((a.size_bytes() or 0) for a in accessors)
|
||||
|
||||
ipc_write_options = _ipc_write_options(compression)
|
||||
output_schema = TableBlockAccessor.try_convert_block_type(
|
||||
blocks[0], block_type=BlockType.ARROW
|
||||
).schema
|
||||
empty_shard = _encode_partition_ipc(output_schema.empty_table(), ipc_write_options)
|
||||
|
||||
partition_accumulators = (
|
||||
{} if total_rows == 0 else _partition_blocks_to_shards(blocks, partition_fn)
|
||||
)
|
||||
|
||||
shard_sizes: Dict[int, Tuple[int, int]] = {}
|
||||
partition_bufs: List[pa.Buffer] = []
|
||||
for partition_id in range(num_partitions):
|
||||
tables = partition_accumulators.pop(partition_id, None)
|
||||
if not tables:
|
||||
partition_bufs.append(empty_shard)
|
||||
continue
|
||||
merged = pa.concat_tables(tables) if len(tables) > 1 else tables[0]
|
||||
shard_sizes[partition_id] = (merged.num_rows, merged.nbytes)
|
||||
partition_bufs.append(_encode_partition_ipc(merged, ipc_write_options))
|
||||
del merged
|
||||
|
||||
input_meta = BlockAccessor.for_block(blocks[0]).get_metadata(
|
||||
block_exec_stats=stats.build(block_ser_time_s=0),
|
||||
)
|
||||
input_meta = replace(input_meta, num_rows=total_rows, size_bytes=total_bytes)
|
||||
return (input_meta, shard_sizes, output_schema), *partition_bufs
|
||||
|
||||
|
||||
def _read_partition_ipc(buf: pa.Buffer) -> Optional[pa.Table]:
|
||||
"""Decompress one partition shard."""
|
||||
if len(buf) == 0:
|
||||
return None
|
||||
reader = pa.ipc.open_stream(buf)
|
||||
schema = reader.schema
|
||||
batches: List[pa.RecordBatch] = []
|
||||
while True:
|
||||
try:
|
||||
batch = reader.read_next_batch()
|
||||
except StopIteration:
|
||||
break
|
||||
if batch.num_rows > 0:
|
||||
batches.append(batch)
|
||||
return pa.Table.from_batches(batches, schema=schema)
|
||||
|
||||
|
||||
# Warn once a shard fetch has stalled for this fraction of the fail timeout
|
||||
_REDUCE_GET_WARN_AT_FRACTION = 1 / 3
|
||||
|
||||
|
||||
def _get_shard_batch(
|
||||
batch: List[ObjectRef],
|
||||
partition_id: int,
|
||||
batch_index: int,
|
||||
num_batches: int,
|
||||
timeout_s: float,
|
||||
) -> List[Optional[pa.Buffer]]:
|
||||
"""``ray.get`` a batch of shard refs, warning then failing if the fetch stalls.
|
||||
|
||||
Args:
|
||||
batch: Shard ObjectRefs to fetch (a slice of one partition's shards).
|
||||
partition_id: Partition this reducer owns (for logging).
|
||||
batch_index: 0-based index of this batch within the partition.
|
||||
num_batches: Total number of batches for the partition (for logging).
|
||||
timeout_s: ``ray.get`` timeout in seconds. A non-positive value disables
|
||||
the timeout (single blocking fetch).
|
||||
|
||||
Returns:
|
||||
The dereferenced shard buffers (some entries may be ``None``).
|
||||
|
||||
Raises:
|
||||
GetTimeoutError: If the shards are not available within ``timeout_s``.
|
||||
"""
|
||||
if timeout_s <= 0:
|
||||
return ray.get(batch)
|
||||
|
||||
wait_start_s = time.perf_counter()
|
||||
warn_timeout_s = timeout_s * _REDUCE_GET_WARN_AT_FRACTION
|
||||
try:
|
||||
return ray.get(batch, timeout=warn_timeout_s)
|
||||
except GetTimeoutError:
|
||||
logger.warning(
|
||||
f"Shuffle reduce task for partition {partition_id} has waited "
|
||||
f"{time.perf_counter() - wait_start_s:.0f}s for {len(batch)} "
|
||||
f"shard(s) in batch {batch_index + 1}/{num_batches}."
|
||||
)
|
||||
|
||||
try:
|
||||
return ray.get(batch, timeout=timeout_s - warn_timeout_s)
|
||||
except GetTimeoutError:
|
||||
logger.error(
|
||||
f"Shuffle reduce task for partition {partition_id} timed out after "
|
||||
f"{time.perf_counter() - wait_start_s:.0f}s waiting for {len(batch)} "
|
||||
f"shard(s) in batch {batch_index + 1}/{num_batches}."
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _gather_input_shards(
|
||||
shard_refs: List[ObjectRef],
|
||||
partition_id: int,
|
||||
batch_size: int,
|
||||
get_timeout_s: float,
|
||||
) -> List[pa.Table]:
|
||||
"""Fetch + decompress every shard of one input for one partition."""
|
||||
tables: List[pa.Table] = []
|
||||
num_batches = math.ceil(len(shard_refs) / batch_size) if batch_size else 0
|
||||
for batch_index, batch_start in enumerate(range(0, len(shard_refs), batch_size)):
|
||||
batch = shard_refs[batch_start : batch_start + batch_size]
|
||||
for buf in _get_shard_batch(
|
||||
batch, partition_id, batch_index, num_batches, get_timeout_s
|
||||
):
|
||||
if buf is None:
|
||||
continue
|
||||
table = _read_partition_ipc(buf)
|
||||
if table is None:
|
||||
continue
|
||||
tables.append(table)
|
||||
return tables
|
||||
|
||||
|
||||
@ray.remote
|
||||
def _shuffle_reduce_task(
|
||||
shard_refs_by_input: List[List[ObjectRef]],
|
||||
partition_id: int,
|
||||
reduce_fn: ReduceFn,
|
||||
target_max_block_size: Optional[int],
|
||||
batch_size: int,
|
||||
get_timeout_s: float,
|
||||
map_transformer: Optional["MapTransformer"],
|
||||
map_task_context: Optional["TaskContext"],
|
||||
data_context: Optional["DataContext"],
|
||||
) -> Generator[Union[Block, bytes], None, None]:
|
||||
"""Reduce stage: fetch this partition's shards and run reduce_fn over them.
|
||||
|
||||
``shard_refs_by_input`` carries one shard-ref list per upstream input -- one
|
||||
for single-input shuffles (repartition/sort), several for multi-input ones
|
||||
(e.g. join). Every input's full shard list is accumulated, then reduce_fn is
|
||||
called once with ``tables_by_input`` aligned with ``shard_refs_by_input``.
|
||||
|
||||
Args:
|
||||
shard_refs_by_input: Per-input lists of ObjectRefs to this partition's
|
||||
IPC shards from every mapper. May contain None for empty shards.
|
||||
partition_id: Partition this reducer owns.
|
||||
reduce_fn: User-supplied reduce callable.
|
||||
target_max_block_size: Output block size. None emits blocks as-is.
|
||||
batch_size: Number of shard refs to ray.get() at a time.
|
||||
get_timeout_s: Timeout for batch ray.get().
|
||||
map_transformer: Fused downstream map applied to reduce output (or None).
|
||||
map_task_context: TaskContext for the fused map, built by the reduce op
|
||||
-- carries task_idx, op_name, the block-size override, and per-task
|
||||
kwargs (e.g. a Write's ``write_uuid``); None when nothing is fused.
|
||||
data_context: DataContext to install for the fused map (or None).
|
||||
"""
|
||||
start_time_s = time.perf_counter()
|
||||
|
||||
output_buffer: Optional[BlockOutputBuffer] = None
|
||||
|
||||
def _yield_with_stats(block: Block):
|
||||
"""Yield a block then its pickled metadata (streaming-gen protocol)."""
|
||||
|
||||
def build_metadata(block_ser_time_s):
|
||||
exec_stats = BlockExecStats.builder()
|
||||
exec_stats.finish()
|
||||
return BlockMetadataWithSchema.from_block(
|
||||
block,
|
||||
block_exec_stats=exec_stats.build(block_ser_time_s=block_ser_time_s),
|
||||
task_exec_stats=TaskExecWorkerStats(
|
||||
task_wall_time_s=time.perf_counter() - start_time_s,
|
||||
),
|
||||
)
|
||||
|
||||
yield from yield_block_with_stats(block, build_metadata)
|
||||
|
||||
def _flush(tables_by_input: List[List[pa.Table]]):
|
||||
nonlocal output_buffer
|
||||
if output_buffer is None:
|
||||
output_buffer = BlockOutputBuffer(
|
||||
OutputBlockSizeOption.of(
|
||||
target_max_block_size=target_max_block_size,
|
||||
)
|
||||
)
|
||||
for block in reduce_fn(partition_id, tables_by_input):
|
||||
output_buffer.add_block(block)
|
||||
# Yield raw blocks: a fused map (and `_yield_with_stats`) is applied
|
||||
# downstream of ``_reduce_output_blocks``.
|
||||
yield from output_buffer.iter_ready_blocks()
|
||||
|
||||
def _reduce_output_blocks():
|
||||
# Gather every input's full shard list, then call reduce_fn exactly once
|
||||
# with all inputs together (no streaming: a multi-input reducer needs
|
||||
# every input's shards, and single-input reducers run blocking too).
|
||||
tables_by_input = [
|
||||
_gather_input_shards(shard_refs, partition_id, batch_size, get_timeout_s)
|
||||
for shard_refs in shard_refs_by_input
|
||||
]
|
||||
if any(tables_by_input):
|
||||
yield from _flush(tables_by_input)
|
||||
|
||||
# Finalize the buffer to flush any partial block.
|
||||
if output_buffer is not None:
|
||||
output_buffer.finalize()
|
||||
yield from output_buffer.iter_ready_blocks()
|
||||
|
||||
if map_transformer is None:
|
||||
for block in _reduce_output_blocks():
|
||||
yield from _yield_with_stats(block)
|
||||
else:
|
||||
assert map_task_context is not None and data_context is not None
|
||||
with DataContext.current(data_context), TaskContext.current(map_task_context):
|
||||
map_transformer.override_target_max_block_size(
|
||||
map_task_context.target_max_block_size_override
|
||||
)
|
||||
for block in map_transformer.apply_transform(
|
||||
_reduce_output_blocks(), map_task_context
|
||||
):
|
||||
yield from _yield_with_stats(block)
|
||||
@@ -0,0 +1,31 @@
|
||||
import typing
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ray.data._internal.progress.base_progress import BaseProgressBar
|
||||
|
||||
|
||||
class SubProgressBarMixin(ABC):
|
||||
"""Abstract class for operators that support sub-progress bars"""
|
||||
|
||||
@abstractmethod
|
||||
def get_sub_progress_bar_names(self) -> Optional[List[str]]:
|
||||
"""
|
||||
Returns list of sub-progress bar names
|
||||
|
||||
This is used to create the sub-progress bars in the progress manager.
|
||||
Note that sub-progress bars will be created in the order returned by
|
||||
this method.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def set_sub_progress_bar(self, name: str, pg: "BaseProgressBar"):
|
||||
"""
|
||||
Sets sub-progress bars
|
||||
|
||||
name: name of sub-progress bar
|
||||
pg: a progress bar. Can be sub-progress bars for rich, tqdm, etc.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,281 @@
|
||||
import copy
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow as pa
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from ray.data._internal.execution.bundle_queue import (
|
||||
BaseBundleQueue,
|
||||
RebundleQueue,
|
||||
)
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
ExecutionResources,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
TaskContext,
|
||||
)
|
||||
from ray.data._internal.execution.operators.map_operator import (
|
||||
MapOperator,
|
||||
_map_task,
|
||||
)
|
||||
from ray.data._internal.execution.operators.map_transformer import MapTransformer
|
||||
from ray.data._internal.remote_fn import cached_remote_fn
|
||||
from ray.data.context import DataContext
|
||||
|
||||
|
||||
class TaskPoolMapOperator(MapOperator):
|
||||
"""A MapOperator implementation that executes tasks on a task pool."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
map_transformer: MapTransformer,
|
||||
input_op: PhysicalOperator,
|
||||
data_context: DataContext,
|
||||
name: str = "TaskPoolMap",
|
||||
target_max_block_size_override: Optional[int] = None,
|
||||
min_rows_per_bundle: Optional[int] = None,
|
||||
ref_bundler: Optional[RebundleQueue] = None,
|
||||
max_concurrency: Optional[int] = None,
|
||||
supports_fusion: bool = True,
|
||||
map_task_kwargs: Optional[Dict[str, Any]] = None,
|
||||
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
|
||||
ray_remote_args: Optional[Dict[str, Any]] = None,
|
||||
on_start: Optional[Callable[[Optional["pa.Schema"]], None]] = None,
|
||||
isolate_workers: bool = False,
|
||||
default_logical_memory_enabled: bool = False,
|
||||
):
|
||||
"""Create an TaskPoolMapOperator instance.
|
||||
|
||||
Args:
|
||||
map_transformer: The :class:`MapTransformer` to apply to each ref
|
||||
bundle input.
|
||||
input_op: Operator generating input data for this op.
|
||||
data_context: The :class:`DataContext` to use for this operator.
|
||||
name: The name of this operator.
|
||||
target_max_block_size_override: Override for target max-block-size.
|
||||
min_rows_per_bundle: The number of rows to gather per batch passed to the
|
||||
transform_fn, or None to use the block size. Setting the batch size is
|
||||
important for the performance of GPU-accelerated transform functions.
|
||||
The actual rows passed may be less if the dataset is small.
|
||||
ref_bundler: The ref bundler to use for this operator.
|
||||
max_concurrency: The maximum number of Ray tasks to use concurrently,
|
||||
or None to use as many tasks as possible.
|
||||
supports_fusion: Whether this operator supports fusion with other operators.
|
||||
map_task_kwargs: A dictionary of kwargs to pass to the map task. You can
|
||||
access these kwargs through the `TaskContext.kwargs` dictionary.
|
||||
ray_remote_args_fn: A function that returns a dictionary of remote args
|
||||
passed to each map worker. The purpose of this argument is to generate
|
||||
dynamic arguments for each actor/task, and will be called each time
|
||||
prior to initializing the worker. Args returned from this dict will
|
||||
always override the args in ``ray_remote_args``. Note: this is an
|
||||
advanced, experimental feature.
|
||||
ray_remote_args: Customize the :func:`ray.remote` args for this op's tasks.
|
||||
on_start: Optional callback invoked with the schema from the first input
|
||||
bundle before any tasks are submitted.
|
||||
isolate_workers: If ``True``, ensure that other operators' tasks don't get
|
||||
scheduled on the same worker processes as this operator's. This flag
|
||||
is useful to prevent side-effects from affecting other operators, like
|
||||
large PyArrow memory allocations.
|
||||
default_logical_memory_enabled: If ``True``, the operator launches tasks
|
||||
with a default logical ``memory``. The method for choosing the
|
||||
default is an implementation detail.
|
||||
"""
|
||||
super().__init__(
|
||||
map_transformer,
|
||||
input_op,
|
||||
data_context,
|
||||
name,
|
||||
target_max_block_size_override,
|
||||
min_rows_per_bundle,
|
||||
ref_bundler,
|
||||
supports_fusion,
|
||||
map_task_kwargs,
|
||||
ray_remote_args_fn,
|
||||
ray_remote_args,
|
||||
on_start,
|
||||
default_logical_memory_enabled,
|
||||
)
|
||||
|
||||
self._isolate_workers = isolate_workers
|
||||
|
||||
if max_concurrency is not None and max_concurrency <= 0:
|
||||
raise ValueError(f"max_concurrency have to be > 0 (got {max_concurrency})")
|
||||
|
||||
self._max_concurrency = max_concurrency
|
||||
self._current_logical_usage = ExecutionResources.zero()
|
||||
|
||||
# NOTE: Unlike static Ray remote args, dynamic arguments extracted from the
|
||||
# blocks themselves are going to be passed inside `fn.options(...)`
|
||||
# invocation
|
||||
ray_remote_static_args = {
|
||||
**(self._ray_remote_args or {}),
|
||||
"num_returns": "streaming",
|
||||
"_labels": {self._OPERATOR_ID_LABEL_KEY: self.id},
|
||||
}
|
||||
|
||||
# Ray Core doesn't share workers for tasks with different `runtime_env`s. We use
|
||||
# this property to implicitly ensure that this operator's tasks run on isolated
|
||||
# workers.
|
||||
if self._isolate_workers:
|
||||
ray_remote_static_args = self._add_unique_runtime_env(
|
||||
ray_remote_static_args
|
||||
)
|
||||
|
||||
self._map_task = cached_remote_fn(_map_task, **ray_remote_static_args)
|
||||
|
||||
def _add_unique_runtime_env(
|
||||
self, ray_remote_args: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a copy of the remote args with a runtime env that's unique to this
|
||||
operator.
|
||||
"""
|
||||
ray_remote_args = copy.deepcopy(ray_remote_args)
|
||||
|
||||
runtime_env = ray_remote_args.get("runtime_env", {})
|
||||
env_vars = ray_remote_args.get("env_vars", {})
|
||||
env_vars["__RAY_DATA_OPERATOR_ID"] = self.id
|
||||
runtime_env["env_vars"] = env_vars
|
||||
|
||||
ray_remote_args["runtime_env"] = runtime_env
|
||||
return ray_remote_args
|
||||
|
||||
@property
|
||||
def isolate_workers(self) -> bool:
|
||||
"""Return whether this operator launches tasks on isolated worker processes.
|
||||
|
||||
If ``True``, other operators' tasks won't get scheduled on the same worker
|
||||
processes as this operator's. This flag is useful to prevent side-effects
|
||||
from affecting other operators, like large PyArrow memory allocations.
|
||||
"""
|
||||
return self._isolate_workers
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._block_ref_bundler]
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._output_queue]
|
||||
|
||||
def _try_schedule_task(self, bundle: RefBundle, strict: bool):
|
||||
# Notify first input for deferred initialization (e.g., Iceberg schema evolution).
|
||||
self._notify_first_input(bundle)
|
||||
# Submit the task as a normal Ray task.
|
||||
ctx = TaskContext(
|
||||
task_idx=self._next_data_task_idx,
|
||||
op_name=self.name,
|
||||
target_max_block_size_override=self.target_max_block_size_override,
|
||||
)
|
||||
|
||||
dynamic_ray_remote_args = self._get_dynamic_ray_remote_args(input_bundle=bundle)
|
||||
dynamic_ray_remote_args["name"] = self.name
|
||||
logical_usage = ExecutionResources.from_resource_dict(dynamic_ray_remote_args)
|
||||
|
||||
if (
|
||||
"_generator_backpressure_num_objects" not in dynamic_ray_remote_args
|
||||
and self.data_context._max_num_blocks_in_streaming_gen_buffer is not None
|
||||
):
|
||||
# The `_generator_backpressure_num_objects` parameter should be
|
||||
# `2 * _max_num_blocks_in_streaming_gen_buffer` because we yield
|
||||
# 2 objects for each block: the block and the block metadata.
|
||||
dynamic_ray_remote_args["_generator_backpressure_num_objects"] = (
|
||||
2 * self.data_context._max_num_blocks_in_streaming_gen_buffer
|
||||
)
|
||||
|
||||
gen = self._map_task.options(**dynamic_ray_remote_args).remote(
|
||||
self._map_transformer_ref,
|
||||
self._data_context_ref,
|
||||
ctx,
|
||||
*bundle.block_refs,
|
||||
slices=bundle.slices,
|
||||
**self.get_map_task_kwargs(),
|
||||
)
|
||||
|
||||
self._current_logical_usage = self._current_logical_usage.add(logical_usage)
|
||||
|
||||
def task_done_callback():
|
||||
self._current_logical_usage = self._current_logical_usage.subtract(
|
||||
logical_usage
|
||||
)
|
||||
|
||||
self._submit_data_task(gen, bundle, task_done_callback=task_done_callback)
|
||||
|
||||
def progress_str(self) -> str:
|
||||
return ""
|
||||
|
||||
def current_logical_usage(self) -> ExecutionResources:
|
||||
return self._current_logical_usage
|
||||
|
||||
def pending_logical_usage(self) -> ExecutionResources:
|
||||
return ExecutionResources()
|
||||
|
||||
def incremental_resource_usage(self) -> ExecutionResources:
|
||||
return self.per_task_resource_allocation()
|
||||
|
||||
def per_task_resource_allocation(self) -> ExecutionResources:
|
||||
return ExecutionResources(
|
||||
cpu=self._ray_remote_args.get("num_cpus", 0),
|
||||
gpu=self._ray_remote_args.get("num_gpus", 0),
|
||||
memory=self._ray_remote_args.get("memory", 0),
|
||||
)
|
||||
|
||||
def min_scheduling_resources(
|
||||
self: "PhysicalOperator",
|
||||
) -> ExecutionResources:
|
||||
return self.incremental_resource_usage()
|
||||
|
||||
def get_max_concurrency_limit(self) -> Optional[int]:
|
||||
return self._max_concurrency
|
||||
|
||||
def min_max_resource_requirements(
|
||||
self,
|
||||
) -> Tuple[ExecutionResources, ExecutionResources]:
|
||||
"""Returns min/max resource requirements for this operator.
|
||||
|
||||
- Min: resources needed for one task (minimum to make progress)
|
||||
- Max: resources for max_concurrency tasks (if set), else infinite
|
||||
"""
|
||||
per_task = self.per_task_resource_allocation()
|
||||
obj_store_per_task = (
|
||||
self._metrics.obj_store_mem_max_pending_output_per_task or 0
|
||||
)
|
||||
|
||||
min_resource_usage = per_task.copy(object_store_memory=obj_store_per_task)
|
||||
|
||||
# Cap resources to 0 if this operator doesn't use them.
|
||||
# This prevents operators from hoarding resource budget they don't need.
|
||||
max_concurrency = (
|
||||
self._max_concurrency if self._max_concurrency is not None else float("inf")
|
||||
)
|
||||
max_resource_usage = ExecutionResources(
|
||||
cpu=0 if per_task.cpu == 0 else per_task.cpu * max_concurrency,
|
||||
gpu=0 if per_task.gpu == 0 else per_task.gpu * max_concurrency,
|
||||
memory=0 if per_task.memory == 0 else per_task.memory * max_concurrency,
|
||||
# Set the max `object_store_memory` requirement to 'inf', because we
|
||||
# don't know how much data each task can output.
|
||||
object_store_memory=float("inf"),
|
||||
)
|
||||
|
||||
return min_resource_usage, max_resource_usage
|
||||
|
||||
def all_inputs_done(self):
|
||||
super().all_inputs_done()
|
||||
|
||||
if (
|
||||
self._max_concurrency is not None
|
||||
and self._metrics.num_inputs_received < self._max_concurrency
|
||||
):
|
||||
warnings.warn(
|
||||
f"The maximum number of concurrent tasks for '{self.name}' is set to "
|
||||
f"{self._max_concurrency}, but the operator only received "
|
||||
f"{self._metrics.num_inputs_received} input(s). This means that the "
|
||||
f"operator can launch at most {self._metrics.num_inputs_received} "
|
||||
"task(s), which is less than the concurrency limit. You might be able "
|
||||
"to increase the number of concurrent tasks by configuring "
|
||||
"`override_num_blocks` earlier in the pipeline."
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
|
||||
|
||||
from ray.data._internal.execution.bundle_queue import BaseBundleQueue, FIFOBundleQueue
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
ExecutionOptions,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.execution.operators.base_physical_operator import (
|
||||
InternalQueueOperatorMixin,
|
||||
NAryOperator,
|
||||
)
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.context import DataContext
|
||||
|
||||
|
||||
class UnionOperator(InternalQueueOperatorMixin, NAryOperator):
|
||||
"""An operator that combines output blocks from
|
||||
two or more input operators into a single output."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_context: DataContext,
|
||||
*input_ops: PhysicalOperator,
|
||||
):
|
||||
"""Create a UnionOperator.
|
||||
|
||||
Args:
|
||||
data_context: The :class:`DataContext` to use for this operator.
|
||||
*input_ops: Operators generating input data for this operator to union.
|
||||
"""
|
||||
|
||||
# By default, union does not preserve the order of output blocks.
|
||||
# To preserve the order, configure ExecutionOptions accordingly.
|
||||
self._preserve_order = False
|
||||
|
||||
# Intermediary buffers used to store blocks from each input dependency.
|
||||
# Only used when `self._prserve_order` is True.
|
||||
self._input_buffers: List["FIFOBundleQueue"] = [
|
||||
FIFOBundleQueue() for _ in range(len(input_ops))
|
||||
]
|
||||
|
||||
self._input_done_flags: List[bool] = [False] * len(input_ops)
|
||||
|
||||
self._output_buffer = FIFOBundleQueue()
|
||||
self._stats: StatsDict = {"Union": []}
|
||||
self._current_input_index = 0
|
||||
super().__init__(data_context, *input_ops)
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_queues(self) -> List["BaseBundleQueue"]:
|
||||
return self._input_buffers
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._output_buffer]
|
||||
|
||||
def start(
|
||||
self,
|
||||
options: ExecutionOptions,
|
||||
block_ref_counter: "BlockRefCounter",
|
||||
):
|
||||
# Whether to preserve deterministic ordering of output blocks.
|
||||
# When True, blocks are emitted in round-robin order across inputs,
|
||||
# ensuring the same input always produces the same output order.
|
||||
self._preserve_order = options.preserve_order
|
||||
super().start(options, block_ref_counter)
|
||||
|
||||
def num_outputs_total(self) -> Optional[int]:
|
||||
num_outputs = 0
|
||||
for input_op in self.input_dependencies:
|
||||
input_num_outputs = input_op.num_outputs_total()
|
||||
if input_num_outputs is None:
|
||||
return None
|
||||
num_outputs += input_num_outputs
|
||||
return num_outputs
|
||||
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
total_rows = 0
|
||||
for input_op in self.input_dependencies:
|
||||
input_num_rows = input_op.num_output_rows_total()
|
||||
if input_num_rows is None:
|
||||
return None
|
||||
total_rows += input_num_rows
|
||||
return total_rows
|
||||
|
||||
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
||||
assert not self.has_completed()
|
||||
assert 0 <= input_index <= len(self._input_dependencies), input_index
|
||||
if self._preserve_order:
|
||||
self._input_buffers[input_index].add(refs)
|
||||
self._metrics.on_input_queued(refs, input_index=input_index)
|
||||
self._try_round_robin()
|
||||
else:
|
||||
self._output_buffer.add(refs)
|
||||
self._metrics.on_output_queued(refs)
|
||||
|
||||
def input_done(self, input_index: int) -> None:
|
||||
self._input_done_flags[input_index] = True
|
||||
if self._preserve_order:
|
||||
self._try_round_robin()
|
||||
|
||||
def all_inputs_done(self) -> None:
|
||||
super().all_inputs_done()
|
||||
|
||||
if not self._preserve_order:
|
||||
return
|
||||
self._try_round_robin()
|
||||
assert all(not buffer.has_next() for buffer in self._input_buffers)
|
||||
|
||||
def has_next(self) -> bool:
|
||||
# Check if the output buffer still contains at least one block.
|
||||
return len(self._output_buffer) > 0
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
refs = self._output_buffer.get_next()
|
||||
self._metrics.on_output_dequeued(refs)
|
||||
return refs
|
||||
|
||||
def get_stats(self) -> StatsDict:
|
||||
return self._stats
|
||||
|
||||
def _try_round_robin(self) -> None:
|
||||
"""Try to move blocks from input buffers to output in round-robin order.
|
||||
|
||||
Pulls one block from the current input, then advances to the next.
|
||||
If the current input's buffer is empty but not done, we return
|
||||
without advancing to the next input so the scheduling won't be blocked.
|
||||
|
||||
This ensures deterministic ordering of output blocks:
|
||||
- We iterate through inputs in a fixed order (0, 1, 2, ..., 0, 1, ...).
|
||||
- We only advance to the next input after consuming exactly one block
|
||||
from the current input (or if the current input is exhausted).
|
||||
- If an input is not ready (empty but not done), we return
|
||||
rather than skipping it, preserving the round-robin sequence.
|
||||
"""
|
||||
num_inputs = len(self._input_buffers)
|
||||
|
||||
while True:
|
||||
buffer = self._input_buffers[self._current_input_index]
|
||||
|
||||
if buffer.has_next():
|
||||
refs = buffer.get_next()
|
||||
self._metrics.on_input_dequeued(
|
||||
refs, input_index=self._current_input_index
|
||||
)
|
||||
self._output_buffer.add(refs)
|
||||
self._metrics.on_output_queued(refs)
|
||||
elif not self._input_done_flags[self._current_input_index] or all(
|
||||
not buffer.has_next() for buffer in self._input_buffers
|
||||
):
|
||||
return
|
||||
|
||||
self._current_input_index = (self._current_input_index + 1) % num_inputs
|
||||
@@ -0,0 +1,333 @@
|
||||
import collections
|
||||
import itertools
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
import ray
|
||||
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
|
||||
from ray.data._internal.execution.bundle_queue import BaseBundleQueue, FIFOBundleQueue
|
||||
from ray.data._internal.execution.interfaces import (
|
||||
BlockEntry,
|
||||
PhysicalOperator,
|
||||
RefBundle,
|
||||
)
|
||||
from ray.data._internal.execution.operators.base_physical_operator import (
|
||||
InternalQueueOperatorMixin,
|
||||
NAryOperator,
|
||||
)
|
||||
from ray.data._internal.remote_fn import cached_remote_fn
|
||||
from ray.data._internal.split import _split_at_indices
|
||||
from ray.data._internal.stats import StatsDict
|
||||
from ray.data.block import (
|
||||
Block,
|
||||
BlockAccessor,
|
||||
BlockExecStats,
|
||||
to_stats,
|
||||
)
|
||||
from ray.data.context import DataContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
from ray.data.block import BlockMetadataWithSchema
|
||||
|
||||
|
||||
class ZipOperator(InternalQueueOperatorMixin, NAryOperator):
|
||||
"""An operator that zips its inputs together.
|
||||
|
||||
NOTE: the implementation is bulk for now, which materializes all its inputs in
|
||||
object store, before starting execution. Should re-implement it as a streaming
|
||||
operator in the future.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_context: DataContext,
|
||||
*input_ops: PhysicalOperator,
|
||||
):
|
||||
"""Create a ZipOperator.
|
||||
|
||||
Args:
|
||||
data_context: The :class:`DataContext` to use for this operator.
|
||||
*input_ops: Operators generating input data for this operator to zip.
|
||||
"""
|
||||
assert len(input_ops) >= 2
|
||||
self._input_buffers: List[FIFOBundleQueue] = [
|
||||
FIFOBundleQueue() for _ in range(len(input_ops))
|
||||
]
|
||||
self._output_buffer: FIFOBundleQueue = FIFOBundleQueue()
|
||||
self._stats: StatsDict = {}
|
||||
super().__init__(
|
||||
data_context,
|
||||
*input_ops,
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_queues(self) -> List["BaseBundleQueue"]:
|
||||
return self._input_buffers
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_queues(self) -> List["BaseBundleQueue"]:
|
||||
return [self._output_buffer]
|
||||
|
||||
def num_outputs_total(self) -> Optional[int]:
|
||||
num_outputs = None
|
||||
for input_op in self.input_dependencies:
|
||||
input_num_outputs = input_op.num_outputs_total()
|
||||
if input_num_outputs is None:
|
||||
continue
|
||||
if num_outputs is None:
|
||||
num_outputs = input_num_outputs
|
||||
else:
|
||||
num_outputs = max(num_outputs, input_num_outputs)
|
||||
return num_outputs
|
||||
|
||||
def num_output_rows_total(self) -> Optional[int]:
|
||||
num_rows = None
|
||||
for input_op in self.input_dependencies:
|
||||
input_num_rows = input_op.num_output_rows_total()
|
||||
if input_num_rows is None:
|
||||
continue
|
||||
if num_rows is None:
|
||||
num_rows = input_num_rows
|
||||
else:
|
||||
num_rows = max(num_rows, input_num_rows)
|
||||
return num_rows
|
||||
|
||||
def _add_input_inner(self, refs: RefBundle, input_index: int) -> None:
|
||||
assert not self.has_completed()
|
||||
assert 0 <= input_index <= len(self._input_dependencies), input_index
|
||||
self._input_buffers[input_index].add(refs)
|
||||
self._metrics.on_input_queued(refs, input_index=input_index)
|
||||
|
||||
def all_inputs_done(self) -> None:
|
||||
assert len(self._output_buffer) == 0, len(self._output_buffer)
|
||||
|
||||
# Start with the first input buffer
|
||||
while self._input_buffers[0].has_next():
|
||||
refs = self._input_buffers[0].get_next()
|
||||
self._output_buffer.add(refs)
|
||||
self._metrics.on_input_dequeued(refs, input_index=0)
|
||||
|
||||
# Process each additional input buffer
|
||||
for idx, input_buffer in enumerate(self._input_buffers[1:], start=1):
|
||||
output_buffer, self._stats = self._zip(self._output_buffer, input_buffer)
|
||||
self._output_buffer = FIFOBundleQueue(bundles=output_buffer)
|
||||
|
||||
# Clear the input buffer AFTER using it in _zip
|
||||
while input_buffer.has_next():
|
||||
refs = input_buffer.get_next()
|
||||
self._metrics.on_input_dequeued(refs, input_index=idx)
|
||||
|
||||
# Zipping creates new blocks; register them for memory tracking.
|
||||
for ref in self._output_buffer:
|
||||
for entry in ref.blocks:
|
||||
self._block_ref_counter.on_block_produced(
|
||||
entry.ref, entry.metadata.size_bytes or 0, self.id
|
||||
)
|
||||
self._metrics.on_output_queued(ref)
|
||||
|
||||
super().all_inputs_done()
|
||||
|
||||
def has_next(self) -> bool:
|
||||
return len(self._output_buffer) > 0
|
||||
|
||||
def _get_next_inner(self) -> RefBundle:
|
||||
refs = self._output_buffer.get_next()
|
||||
self._metrics.on_output_dequeued(refs)
|
||||
return refs
|
||||
|
||||
def get_stats(self) -> StatsDict:
|
||||
return self._stats
|
||||
|
||||
def throttling_disabled(self) -> bool:
|
||||
# TODO revert once zip becomes streaming
|
||||
return True
|
||||
|
||||
def _zip(
|
||||
self,
|
||||
left_input: FIFOBundleQueue,
|
||||
right_input: FIFOBundleQueue,
|
||||
) -> Tuple[collections.deque[RefBundle], StatsDict]:
|
||||
"""Zip the RefBundles from `left_input` and `right_input` together.
|
||||
|
||||
Zip is done in 2 steps: aligning blocks, and zipping blocks from
|
||||
both sides.
|
||||
|
||||
Aligning blocks (optional): check the blocks from `left_input` and
|
||||
`right_input` are aligned or not, i.e. if having different number of blocks, or
|
||||
having different number of rows in some blocks. If not aligned, repartition the
|
||||
smaller input with `_split_at_indices` to align with larger input.
|
||||
|
||||
Zipping blocks: after blocks from both sides are aligned, zip
|
||||
blocks from both sides together in parallel.
|
||||
"""
|
||||
left_entries: List[BlockEntry] = []
|
||||
for bundle in left_input:
|
||||
left_entries.extend(bundle.blocks)
|
||||
right_entries: List[BlockEntry] = []
|
||||
for bundle in right_input:
|
||||
right_entries.extend(bundle.blocks)
|
||||
|
||||
left_block_rows, left_block_bytes = self._calculate_blocks_rows_and_bytes(
|
||||
left_entries
|
||||
)
|
||||
right_block_rows, right_block_bytes = self._calculate_blocks_rows_and_bytes(
|
||||
right_entries
|
||||
)
|
||||
|
||||
# Check that both sides have the same number of rows.
|
||||
# TODO(Clark): Support different number of rows via user-directed
|
||||
# dropping/padding.
|
||||
total_left_rows = sum(left_block_rows)
|
||||
total_right_rows = sum(right_block_rows)
|
||||
if total_left_rows != total_right_rows:
|
||||
raise ValueError(
|
||||
"Cannot zip datasets of different number of rows: "
|
||||
f"{total_left_rows}, {total_right_rows}"
|
||||
)
|
||||
|
||||
# Whether the left and right input sides are inverted
|
||||
input_side_inverted = False
|
||||
if sum(right_block_bytes) > sum(left_block_bytes):
|
||||
# Make sure that right side is smaller, so we minimize splitting
|
||||
# work when aligning both sides.
|
||||
# TODO(Clark): Improve this heuristic for minimizing splitting work,
|
||||
# e.g. by generating the splitting plans for each route (via
|
||||
# _generate_per_block_split_indices) and choosing the plan that splits
|
||||
# the least cumulative bytes.
|
||||
left_entries, right_entries = right_entries, left_entries
|
||||
left_block_rows, right_block_rows = right_block_rows, left_block_rows
|
||||
input_side_inverted = True
|
||||
|
||||
# Get the split indices that will align both sides.
|
||||
indices = list(itertools.accumulate(left_block_rows))
|
||||
indices.pop(-1)
|
||||
|
||||
# Split other at the alignment indices, such that for every block from
|
||||
# left side, we have a list of blocks from right side that have the same
|
||||
# cumulative number of rows as that left block.
|
||||
# NOTE: _split_at_indices has a no-op fastpath if the blocks are already
|
||||
# aligned.
|
||||
# Determine the ownership of the blocks being split, accounting for the
|
||||
# potential swap above. We must not free blocks that are shared with
|
||||
# other operators (e.g., when the input RefBundle has owns_blocks=False
|
||||
# because it comes from a materialized dataset).
|
||||
split_side_owned = all(
|
||||
b.owns_blocks for b in (left_input if input_side_inverted else right_input)
|
||||
)
|
||||
label_selector = self.data_context.execution_options.label_selector
|
||||
aligned_right_blocks_with_metadata = _split_at_indices(
|
||||
[(e.ref, e.metadata) for e in right_entries],
|
||||
indices,
|
||||
owned_by_consumer=split_side_owned,
|
||||
block_rows=right_block_rows,
|
||||
label_selector=label_selector,
|
||||
)
|
||||
del right_entries
|
||||
|
||||
left_blocks = [e.ref for e in left_entries]
|
||||
right_blocks_list = aligned_right_blocks_with_metadata[0]
|
||||
del left_entries, aligned_right_blocks_with_metadata
|
||||
|
||||
zip_one_block = cached_remote_fn(_zip_one_block, num_returns=2)
|
||||
if label_selector:
|
||||
zip_one_block = zip_one_block.options(label_selector=label_selector)
|
||||
|
||||
output_blocks = []
|
||||
output_metadata_schema = []
|
||||
for left_block, right_blocks in zip(left_blocks, right_blocks_list):
|
||||
# For each block from left side, zip it together with 1 or more blocks from
|
||||
# right side. We're guaranteed to have that left_block has the same number
|
||||
# of rows as right_blocks has cumulatively.
|
||||
res, meta_with_schema = zip_one_block.remote(
|
||||
left_block, *right_blocks, inverted=input_side_inverted
|
||||
)
|
||||
output_blocks.append(res)
|
||||
output_metadata_schema.append(meta_with_schema)
|
||||
|
||||
# Early release memory.
|
||||
del left_blocks, right_blocks_list
|
||||
|
||||
# TODO(ekl) it might be nice to have a progress bar here.
|
||||
output_metadata_schema: List[BlockMetadataWithSchema] = ray.get(
|
||||
output_metadata_schema
|
||||
)
|
||||
|
||||
output_refs: collections.deque[RefBundle] = collections.deque()
|
||||
input_owned = all(b.owns_blocks for b in left_input)
|
||||
for block, meta_with_schema in zip(output_blocks, output_metadata_schema):
|
||||
output_refs.append(
|
||||
RefBundle(
|
||||
[BlockEntry(block, meta_with_schema.metadata)],
|
||||
owns_blocks=input_owned,
|
||||
schema=meta_with_schema.schema,
|
||||
)
|
||||
)
|
||||
stats = {self._name: to_stats(output_metadata_schema)}
|
||||
|
||||
# Clean up inputs.
|
||||
for ref in left_input:
|
||||
ref.destroy_if_owned()
|
||||
for ref in right_input:
|
||||
ref.destroy_if_owned()
|
||||
|
||||
return output_refs, stats
|
||||
|
||||
def _calculate_blocks_rows_and_bytes(
|
||||
self,
|
||||
entries: List[BlockEntry],
|
||||
) -> Tuple[List[int], List[int]]:
|
||||
"""Calculate the number of rows and size in bytes for a list of blocks with
|
||||
metadata.
|
||||
"""
|
||||
get_num_rows_and_bytes = cached_remote_fn(_get_num_rows_and_bytes)
|
||||
label_selector = self.data_context.execution_options.label_selector
|
||||
if label_selector:
|
||||
get_num_rows_and_bytes = get_num_rows_and_bytes.options(
|
||||
label_selector=label_selector
|
||||
)
|
||||
block_rows = []
|
||||
block_bytes = []
|
||||
for entry in entries:
|
||||
metadata = entry.metadata
|
||||
if metadata.num_rows is None or metadata.size_bytes is None:
|
||||
# Need to fetch number of rows or size in bytes, so just fetch both.
|
||||
num_rows, size_bytes = ray.get(get_num_rows_and_bytes.remote(entry.ref))
|
||||
# Cache on the block metadata.
|
||||
metadata = replace(metadata, num_rows=num_rows, size_bytes=size_bytes)
|
||||
block_rows.append(metadata.num_rows)
|
||||
block_bytes.append(metadata.size_bytes)
|
||||
return block_rows, block_bytes
|
||||
|
||||
|
||||
def _zip_one_block(
|
||||
block: Block, *other_blocks: Block, inverted: bool = False
|
||||
) -> Tuple[Block, "BlockMetadataWithSchema"]:
|
||||
"""Zip together `block` with `other_blocks`."""
|
||||
stats = BlockExecStats.builder()
|
||||
# Concatenate other blocks.
|
||||
# TODO(Clark): Extend BlockAccessor.zip() to work with N other blocks,
|
||||
# so we don't need to do this concatenation.
|
||||
builder = DelegatingBlockBuilder()
|
||||
for other_block in other_blocks:
|
||||
builder.add_block(other_block)
|
||||
other_block = builder.build()
|
||||
if inverted:
|
||||
# Swap blocks if ordering was inverted during block alignment splitting.
|
||||
block, other_block = other_block, block
|
||||
# Zip block and other blocks.
|
||||
result = BlockAccessor.for_block(block).zip(other_block)
|
||||
from ray.data.block import BlockMetadataWithSchema
|
||||
|
||||
return result, BlockMetadataWithSchema.from_block(
|
||||
result, block_exec_stats=stats.build()
|
||||
)
|
||||
|
||||
|
||||
def _get_num_rows_and_bytes(block: Block) -> Tuple[int, int]:
|
||||
block = BlockAccessor.for_block(block)
|
||||
return block.num_rows(), block.size_bytes()
|
||||
Reference in New Issue
Block a user