chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,123 @@
from typing import List, Tuple, Union
from ray.data._internal.planner.exchange.interfaces import ExchangeTaskSpec
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
from ray.data._internal.table_block import TableBlockAccessor
from ray.data.aggregate import AggregateFn, AggregateFnV2, Count
from ray.data.block import (
Block,
BlockAccessor,
BlockExecStats,
BlockMetadataWithSchema,
KeyType,
)
class SortAggregateTaskSpec(ExchangeTaskSpec):
"""
The implementation for sort-based aggregate tasks.
Aggregate is done in 2 steps: partial aggregate of individual blocks, and
final aggregate of sorted blocks.
Partial aggregate (`map`): each block is sorted locally, then partitioned into
smaller blocks according to the boundaries. Each partitioned block is aggregated
separately, then passed to a final aggregate task.
Final aggregate (`reduce`): each task would receive a block from every worker that
consists of items in a certain range. It then merges the sorted blocks and
aggregates on-the-fly.
"""
def __init__(
self,
boundaries: List[KeyType],
key: SortKey,
aggs: List[AggregateFn],
):
super().__init__(
map_args=[boundaries, key, aggs],
reduce_args=[key, aggs],
)
@staticmethod
def map(
idx: int,
block: Block,
output_num_blocks: int,
boundaries: List[KeyType],
sort_key: SortKey,
aggs: List[AggregateFn],
) -> List[Union[Block, "BlockMetadataWithSchema"]]:
stats = BlockExecStats.builder()
block = SortAggregateTaskSpec._prune_unused_columns(block, sort_key, aggs)
if sort_key.get_columns():
partitions = BlockAccessor.for_block(block).sort_and_partition(
boundaries,
sort_key,
)
else:
partitions = [block]
parts = [
BlockAccessor.for_block(p)._aggregate(sort_key, aggs) for p in partitions
]
from ray.data.block import BlockMetadataWithSchema
meta_with_schema = BlockMetadataWithSchema.from_block(
block, block_exec_stats=stats.build()
)
return parts + [meta_with_schema]
@staticmethod
def reduce(
key: SortKey,
aggs: List[AggregateFn],
*mapper_outputs: List[Block],
partial_reduce: bool = False,
) -> Tuple[Block, "BlockMetadataWithSchema"]:
normalized_blocks = TableBlockAccessor.normalize_block_types(
mapper_outputs,
target_block_type=None,
)
blocks, meta_with_schema = BlockAccessor.for_block(
normalized_blocks[0]
)._combine_aggregated_blocks(
list(normalized_blocks), key, aggs, finalize=not partial_reduce
)
return blocks, meta_with_schema
@staticmethod
def _prune_unused_columns(
block: Block,
sort_key: SortKey,
aggs: Tuple[AggregateFn],
) -> Block:
"""Prune unused columns from block before aggregate."""
prune_columns = True
columns = set()
key = sort_key.get_columns()
if isinstance(key, str):
columns.add(key)
elif isinstance(key, list):
columns.update(key)
elif callable(key):
prune_columns = False
for agg in aggs:
if isinstance(agg, AggregateFnV2) and agg.get_target_column():
columns.add(agg.get_target_column())
elif not isinstance(agg, Count):
# Don't prune columns if any aggregate key is not string.
prune_columns = False
block_accessor = BlockAccessor.for_block(block)
if (
prune_columns
and isinstance(block_accessor, TableBlockAccessor)
and block_accessor.num_rows() > 0
):
return block_accessor.select(list(columns))
else:
return block
@@ -0,0 +1,135 @@
import logging
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
import ray._private.worker
from ray.data._internal.execution.interfaces import RefBundle
from ray.data._internal.stats import StatsDict
from ray.data._internal.util import convert_bytes_to_human_readable_str
from ray.data.block import Block
from ray.data.context import DataContext
if TYPE_CHECKING:
from ray.data.block import BlockMetadataWithSchema
logger = logging.getLogger(__name__)
class ExchangeTaskSpec:
"""
An interface to specify the exchange map and reduce tasks.
Subclasses should implement the `map` and `reduce` static methods.
`map` method is to transform one input block into multiple output blocks.
`reduce` is to combine multiple map output blocks. Both methods are
single-task operations. See `ExchangeScheduler` for how to distribute
the operations across multiple tasks.
Any custom arguments for `map` and `reduce` methods should be specified by
setting `map_args` and `reduce_args`.
The concept here is similar to the exchange operator described in
"Volcano - An Extensible and Parallel Query Evaluation System"
(https://dl.acm.org/doi/10.1109/69.273032).
"""
MAP_SUB_PROGRESS_BAR_NAME = "Shuffle Map"
REDUCE_SUB_PROGRESS_BAR_NAME = "Shuffle Reduce"
def __init__(self, map_args: List[Any] = None, reduce_args: List[Any] = None):
self._map_args = map_args or []
self._reduce_args = reduce_args or []
assert isinstance(self._map_args, list)
assert isinstance(self._reduce_args, list)
@staticmethod
def map(
idx: int,
block: Block,
output_num_blocks: int,
) -> List[Union[Block, "BlockMetadataWithSchema"]]:
"""
Map function to be run on each input block.
Returns list of [BlockMetadata, Block1, Block2, ..., BlockN].
"""
raise NotImplementedError
@staticmethod
def reduce(
*mapper_outputs: List[Block],
partial_reduce: bool = False,
) -> Tuple[Block, "BlockMetadataWithSchema"]:
"""
Reduce function to be run for each output block.
Args:
*mapper_outputs: List of map output blocks to reduce.
partial_reduce: Whether should partially or fully reduce.
Returns:
The reduced block and its metadata.
"""
raise NotImplementedError
class ExchangeTaskScheduler:
"""
An interface to schedule exchange tasks (`exchange_spec`) for multi-nodes
execution.
"""
def __init__(self, exchange_spec: ExchangeTaskSpec):
"""Initialize the scheduler.
Args:
exchange_spec: The implementation of exchange tasks to execute.
"""
self._exchange_spec = exchange_spec
# If driver memory exceeds this threshold, warn the user. For now, this
# only applies to shuffle ops because most other ops are unlikely to use as
# much driver memory.
self.warn_on_driver_memory_usage_bytes: Optional[
int
] = DataContext.get_current().warn_on_driver_memory_usage_bytes
def execute(
self,
refs: List[RefBundle],
output_num_blocks: int,
map_ray_remote_args: Optional[Dict[str, Any]] = None,
reduce_ray_remote_args: Optional[Dict[str, Any]] = None,
warn_on_driver_memory_usage: Optional[int] = None,
) -> Tuple[List[RefBundle], StatsDict]:
"""
Execute the exchange tasks on input `refs`.
"""
raise NotImplementedError
def warn_on_high_local_memory_store_usage(self) -> None:
ray_core_worker = ray._private.worker.global_worker.core_worker
local_memory_store_bytes_used = (
ray_core_worker.get_local_memory_store_bytes_used()
)
self.warn_on_driver_memory_usage(
local_memory_store_bytes_used,
"More than "
f"{convert_bytes_to_human_readable_str(local_memory_store_bytes_used)} "
"of driver memory used to store Ray Data block data and metadata. "
"This job may exit if driver memory is insufficient.\n\n"
"This can happen when many tiny blocks are created. "
"Check the block size using Dataset.stats() and see "
"https://docs.ray.io/en/latest/data/performance-tips.html"
" for mitigation.",
)
def warn_on_driver_memory_usage(
self, memory_usage_bytes: int, log_str: str
) -> None:
if self.warn_on_driver_memory_usage_bytes is None:
return
if memory_usage_bytes > self.warn_on_driver_memory_usage_bytes:
logger.warning(log_str)
# Double the threshold to avoid verbose warnings.
self.warn_on_driver_memory_usage_bytes = memory_usage_bytes * 2
@@ -0,0 +1,155 @@
import logging
from typing import Any, Dict, List, Optional, Tuple
from ray._private.ray_constants import CALLER_MEMORY_USAGE_PER_OBJECT_REF
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle, TaskContext
from ray.data._internal.planner.exchange.interfaces import (
ExchangeTaskScheduler,
ExchangeTaskSpec,
)
from ray.data._internal.remote_fn import cached_remote_fn
from ray.data._internal.stats import StatsDict
from ray.data._internal.util import (
convert_bytes_to_human_readable_str,
unzip,
)
from ray.data.block import BlockMetadataWithSchema, to_stats
logger = logging.getLogger(__name__)
class PullBasedShuffleTaskScheduler(ExchangeTaskScheduler):
"""
The pull-based map-reduce shuffle scheduler.
Map tasks are first scheduled to generate map output blocks. After all map output
are generated, then reduce tasks are scheduled to combine map output blocks
together.
The concept here is similar to
"MapReduce: Simplified Data Processing on Large Clusters"
(https://dl.acm.org/doi/10.1145/1327452.1327492).
"""
def execute(
self,
refs: List[RefBundle],
output_num_blocks: int,
task_ctx: TaskContext,
map_ray_remote_args: Optional[Dict[str, Any]] = None,
reduce_ray_remote_args: Optional[Dict[str, Any]] = None,
_debug_limit_execution_to_num_blocks: Optional[int] = None,
) -> Tuple[List[RefBundle], StatsDict]:
# TODO: eagerly delete the input and map output block references in order to
# eagerly release the blocks' memory.
input_blocks_list = []
for ref_bundle in refs:
input_blocks_list.extend(ref_bundle.block_refs)
input_num_blocks = len(input_blocks_list)
input_owned = all(b.owns_blocks for b in refs)
caller_memory_usage = (
input_num_blocks * output_num_blocks * CALLER_MEMORY_USAGE_PER_OBJECT_REF
)
self.warn_on_driver_memory_usage(
caller_memory_usage,
"Execution is estimated to use at least "
f"{convert_bytes_to_human_readable_str(caller_memory_usage)} "
"of driver memory. Ensure that the driver machine has at least "
"this much memory to ensure job completion.\n\n"
"To reduce the "
"amount of driver memory needed, enable push-based shuffle using "
"RAY_DATA_PUSH_BASED_SHUFFLE=1 "
"(https://docs.ray.io/en/latest/data/performance-tips.html"
").",
)
if map_ray_remote_args is None:
map_ray_remote_args = {}
if reduce_ray_remote_args is None:
reduce_ray_remote_args = {}
if "scheduling_strategy" not in reduce_ray_remote_args:
reduce_ray_remote_args = reduce_ray_remote_args.copy()
reduce_ray_remote_args["scheduling_strategy"] = "SPREAD"
shuffle_map = cached_remote_fn(self._exchange_spec.map)
shuffle_reduce = cached_remote_fn(self._exchange_spec.reduce)
sub_progress_bar_dict = task_ctx.sub_progress_bar_dict
bar_name = ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME
assert bar_name in sub_progress_bar_dict, sub_progress_bar_dict
map_bar = sub_progress_bar_dict[bar_name]
if _debug_limit_execution_to_num_blocks is not None:
input_blocks_list = input_blocks_list[:_debug_limit_execution_to_num_blocks]
logger.debug(f"Limiting execution to {len(input_blocks_list)} map tasks")
shuffle_map_out = [
shuffle_map.options(
**map_ray_remote_args,
num_returns=1 + output_num_blocks,
).remote(i, block, output_num_blocks, *self._exchange_spec._map_args)
for i, block in enumerate(input_blocks_list)
]
# The first item returned is the BlockMetadata.
shuffle_map_metadata_schema = []
for i, refs in enumerate(shuffle_map_out):
shuffle_map_metadata_schema.append(refs[-1])
shuffle_map_out[i] = refs[:-1]
if _debug_limit_execution_to_num_blocks is not None:
while len(shuffle_map_out) < output_num_blocks:
# Repeat the first map task's results.
shuffle_map_out.append(shuffle_map_out[0][:])
shuffle_map_metadata_schema = map_bar.fetch_until_complete(
shuffle_map_metadata_schema
)
self.warn_on_high_local_memory_store_usage()
bar_name = ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME
assert bar_name in sub_progress_bar_dict, sub_progress_bar_dict
reduce_bar = sub_progress_bar_dict[bar_name]
if _debug_limit_execution_to_num_blocks is not None:
output_num_blocks = _debug_limit_execution_to_num_blocks
logger.debug(f"Limiting execution to {output_num_blocks} reduce tasks")
shuffle_reduce_out = [
shuffle_reduce.options(**reduce_ray_remote_args, num_returns=2).remote(
*self._exchange_spec._reduce_args,
*[shuffle_map_out[i][j] for i in range(input_num_blocks)],
)
for j in range(output_num_blocks)
]
# Release map task outputs from the Ray object store.
del shuffle_map_out
new_blocks, new_metadata_schema = [], []
if shuffle_reduce_out:
new_blocks, new_metadata_schema = unzip(shuffle_reduce_out)
new_metadata_schema: List[
"BlockMetadataWithSchema"
] = reduce_bar.fetch_until_complete(list(new_metadata_schema))
self.warn_on_high_local_memory_store_usage()
output = []
for block, meta_with_schema in zip(new_blocks, new_metadata_schema):
output.append(
RefBundle(
[BlockEntry(block, meta_with_schema.metadata)],
owns_blocks=input_owned,
schema=meta_with_schema.schema,
)
)
stats = {
"map": to_stats(shuffle_map_metadata_schema),
"reduce": to_stats(new_metadata_schema),
}
return (output, stats)
@@ -0,0 +1,847 @@
import logging
import math
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeVar, Union
import ray
from ray._private.ray_constants import CALLER_MEMORY_USAGE_PER_OBJECT_REF
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle, TaskContext
from ray.data._internal.planner.exchange.interfaces import (
ExchangeTaskScheduler,
ExchangeTaskSpec,
)
from ray.data._internal.remote_fn import cached_remote_fn
from ray.data._internal.stats import StatsDict
from ray.data._internal.util import (
convert_bytes_to_human_readable_str,
unzip,
)
from ray.data.block import (
Block,
BlockAccessor,
BlockExecStats,
BlockMetadata,
BlockMetadataWithSchema,
_take_first_non_empty_schema,
to_stats,
)
from ray.data.context import DataContext
from ray.types import ObjectRef
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
if TYPE_CHECKING:
from ray.data._internal.progress.base_progress import BaseProgressBar
from ray.data.block import BlockMetadataWithSchema
logger = logging.getLogger(__name__)
T = TypeVar("T")
U = TypeVar("U")
class _MergeTaskSchedule:
def __init__(self, output_num_blocks: int, num_merge_tasks_per_round: int):
self.output_num_blocks = output_num_blocks
self.num_merge_tasks_per_round = num_merge_tasks_per_round
self.num_reducers_per_merger = output_num_blocks // num_merge_tasks_per_round
self._num_mergers_with_extra_reducer = (
output_num_blocks % num_merge_tasks_per_round
)
if self.num_reducers_per_merger == 0:
self.num_merge_tasks_per_round = self._num_mergers_with_extra_reducer
self.num_reducers_per_merger = 1
self._num_mergers_with_extra_reducer = 0
def __repr__(self):
return (
f" num merge tasks per round: {self.num_merge_tasks_per_round}\n"
f" num reduce tasks per merge task: {self.num_reducers_per_merger}\n"
" num merge tasks with extra reduce task: "
f"{self._num_mergers_with_extra_reducer}"
)
def get_num_reducers_per_merge_idx(self, merge_idx: int) -> int:
"""
Each intermediate merge task will produce outputs for a partition of P
final reduce tasks. This helper function returns P based on the merge
task index.
"""
assert merge_idx < self.num_merge_tasks_per_round
num_reducers_for_cur_merger = self.num_reducers_per_merger
if merge_idx < self._num_mergers_with_extra_reducer:
num_reducers_for_cur_merger += 1
return num_reducers_for_cur_merger
def get_merge_idx_for_reducer_idx(self, reducer_idx: int) -> int:
if (
reducer_idx
< (self.num_reducers_per_merger + 1) * self._num_mergers_with_extra_reducer
):
merge_idx = reducer_idx // (self.num_reducers_per_merger + 1)
else:
reducer_idx -= (
self.num_reducers_per_merger + 1
) * self._num_mergers_with_extra_reducer
merge_idx = (
self._num_mergers_with_extra_reducer
+ reducer_idx // self.num_reducers_per_merger
)
assert merge_idx < self.num_merge_tasks_per_round
return merge_idx
def round_robin_reduce_idx_iterator(self):
"""
When there are multiple nodes, merge tasks are spread throughout the
cluster to improve load-balancing. Each merge task produces outputs for
a contiguous partition of reduce tasks. This method creates an iterator
that returns reduce task indices round-robin across the merge tasks.
This can be used to submit reduce tasks in a way that spreads the load
evenly across the cluster.
"""
idx = 0
round_idx = 0
while idx < self.output_num_blocks:
for merge_idx in range(self.num_merge_tasks_per_round):
if merge_idx < self._num_mergers_with_extra_reducer:
reduce_idx = merge_idx * (self.num_reducers_per_merger + 1)
num_reducers_for_cur_merger = self.num_reducers_per_merger + 1
else:
reduce_idx = self._num_mergers_with_extra_reducer * (
self.num_reducers_per_merger + 1
)
merge_idx -= self._num_mergers_with_extra_reducer
reduce_idx += merge_idx * self.num_reducers_per_merger
num_reducers_for_cur_merger = self.num_reducers_per_merger
if round_idx >= num_reducers_for_cur_merger:
continue
reduce_idx += round_idx
yield reduce_idx
idx += 1
round_idx += 1
class _PushBasedShuffleStage:
def __init__(
self,
output_num_blocks: int,
num_rounds: int,
num_map_tasks_per_round: int,
merge_task_placement: List[str],
):
# The number of rounds of map-merge tasks. Reducer tasks are given the
# outputs of the merge tasks as inputs. Reducer tasks receive one input
# per round.
self.num_rounds = num_rounds
# The number of map tasks per round of map-merge tasks. The map task
# produces one output per merge task in the same round.
self.num_map_tasks_per_round = num_map_tasks_per_round
node_strategies = {
node_id: {
"scheduling_strategy": NodeAffinitySchedulingStrategy(
node_id, soft=True
)
}
for node_id in set(merge_task_placement)
}
self._merge_task_options = [
node_strategies[node_id] for node_id in merge_task_placement
]
self.merge_schedule = _MergeTaskSchedule(
output_num_blocks, len(merge_task_placement)
)
def get_estimated_num_refs(self) -> int:
# Number of intermediate blocks = Number of rounds x (map tasks per
# round * merge tasks per round).
num_intermediate_refs = self.num_rounds * (
self.num_map_tasks_per_round * self.merge_schedule.num_merge_tasks_per_round
)
# Number of input blocks + intermediate blocks + output blocks.
num_refs_total = (
(self.num_rounds * self.num_map_tasks_per_round)
+ num_intermediate_refs
+ self.merge_schedule.output_num_blocks
)
return num_refs_total
def get_merge_task_options(self, merge_idx):
return self._merge_task_options[merge_idx]
def __repr__(self):
return (
"num map tasks per round (num args per merge task): "
f"{self.num_map_tasks_per_round}\n"
f"num rounds (num args per reduce task): {self.num_rounds}\n"
"merge task placement: \n"
f"{self.merge_schedule}"
)
class _PipelinedStageExecutor:
def __init__(
self,
stage_iter,
num_tasks_per_round: int,
max_concurrent_rounds: int = 1,
progress_bar: Optional["BaseProgressBar"] = None,
):
self._stage_iter = stage_iter
self._num_tasks_per_round = num_tasks_per_round
self._max_concurrent_rounds = max_concurrent_rounds
self._progress_bar = progress_bar
self._rounds: List[List[ObjectRef]] = []
self._task_idx = 0
self._submit_round()
self._num_block_bytes_stored_at_driver = 0
def __iter__(self):
return self
def __next__(self) -> List["BlockMetadataWithSchema"]:
"""
Submit one round of tasks. If we already have the max concurrent rounds
in flight, first wait for the oldest round of tasks to finish.
"""
prev_metadata_and_schema = []
if all(len(r) == 0 for r in self._rounds):
raise StopIteration
if len(self._rounds) >= self._max_concurrent_rounds:
prev_metadata_schema_refs = self._rounds.pop(0)
if prev_metadata_schema_refs:
if self._progress_bar is not None:
prev_metadata_and_schema = self._progress_bar.fetch_until_complete(
prev_metadata_schema_refs
)
# TODO(swang): Eagerly free the previous round's args.
# See https://github.com/ray-project/ray/issues/42145.
else:
prev_metadata_and_schema = ray.get(prev_metadata_schema_refs)
self._submit_round()
return prev_metadata_and_schema
def _submit_round(self):
assert len(self._rounds) < self._max_concurrent_rounds
task_round = []
for _ in range(self._num_tasks_per_round):
try:
task_round.append(next(self._stage_iter))
except StopIteration:
break
self._rounds.append(task_round)
class _MapStageIterator:
def __init__(self, input_blocks_list, shuffle_map, map_args):
self._input_blocks_list = input_blocks_list
self._shuffle_map = shuffle_map
self._map_args = map_args
self._mapper_idx = 0
self._map_results = []
def __iter__(self):
return self
def __next__(self):
if not self._input_blocks_list:
raise StopIteration
block = self._input_blocks_list.pop(0)
# NOTE(swang): Results are shuffled between map and merge tasks, so
# there is no advantage to colocating specific map and merge tasks.
# Therefore, we do not specify a node affinity policy for map tasks
# in case the caller or Ray has a better scheduling strategy, e.g.,
# based on data locality.
map_result = self._shuffle_map.remote(
self._mapper_idx,
block,
*self._map_args,
)
metadata_schema_ref = map_result.pop(-1)
self._map_results.append(map_result)
self._mapper_idx += 1
return metadata_schema_ref
def pop_map_results(self) -> List[List[ObjectRef]]:
map_results = self._map_results
self._map_results = []
return map_results
class _MergeStageIterator:
def __init__(
self,
map_stage_iter: _MapStageIterator,
shuffle_merge,
stage: _PushBasedShuffleStage,
reduce_args,
):
self._map_stage_iter = map_stage_iter
self._shuffle_merge = shuffle_merge
self._stage = stage
self._reduce_args = reduce_args
self._merge_idx = 0
self._map_result_buffer = None
# Final outputs from the map-merge stage.
# This is a map from merge task index to a nested list of merge results
# (ObjectRefs). Each merge task index corresponds to a partition of P
# final reduce tasks.
self._all_merge_results = [
[] for _ in range(self._stage.merge_schedule.num_merge_tasks_per_round)
]
def __next__(self):
if not self._map_result_buffer or not self._map_result_buffer[0]:
assert self._merge_idx == 0
self._map_result_buffer = self._map_stage_iter.pop_map_results()
if not self._map_result_buffer:
raise StopIteration
# Shuffle the map results for the merge tasks.
merge_args = [map_result.pop(0) for map_result in self._map_result_buffer]
num_merge_returns = self._stage.merge_schedule.get_num_reducers_per_merge_idx(
self._merge_idx
)
merge_result = self._shuffle_merge.options(
num_returns=1 + num_merge_returns,
**self._stage.get_merge_task_options(self._merge_idx),
).remote(
*merge_args,
reduce_args=self._reduce_args,
)
metadata_schema_ref = merge_result.pop(-1)
self._all_merge_results[self._merge_idx].append(merge_result)
del merge_result
self._merge_idx += 1
self._merge_idx %= self._stage.merge_schedule.num_merge_tasks_per_round
return metadata_schema_ref
def pop_merge_results(self) -> List[List[ObjectRef]]:
"""Return a nested list of merge task results. The list at index i
stores the outputs of the i-th merge task submitted during each
map-merge round. Each merge task returns a list of outputs because it
may produce outputs for multiple downstream reduce tasks.
"""
all_merge_results = self._all_merge_results
self._all_merge_results = []
return all_merge_results
class _ReduceStageIterator:
def __init__(
self,
stage: _PushBasedShuffleStage,
shuffle_reduce,
all_merge_results: List[List[List[ObjectRef]]],
ray_remote_args,
reduce_args: List[Any],
_debug_limit_execution_to_num_blocks: Optional[int],
):
self._shuffle_reduce = shuffle_reduce
self._stage = stage
self._reduce_arg_blocks: List[Tuple[int, List[ObjectRef]]] = []
self._ray_remote_args = ray_remote_args
self._reduce_args = reduce_args
for reduce_idx in self._stage.merge_schedule.round_robin_reduce_idx_iterator():
merge_idx = self._stage.merge_schedule.get_merge_idx_for_reducer_idx(
reduce_idx
)
reduce_arg_blocks = [
merge_results.pop(0) for merge_results in all_merge_results[merge_idx]
]
self._reduce_arg_blocks.append((reduce_idx, reduce_arg_blocks))
assert len(self._reduce_arg_blocks) == stage.merge_schedule.output_num_blocks
if _debug_limit_execution_to_num_blocks is not None:
self._reduce_arg_blocks = self._reduce_arg_blocks[
:_debug_limit_execution_to_num_blocks
]
logger.debug(
f"Limiting execution to {len(self._reduce_arg_blocks)} reduce tasks"
)
for merge_idx, merge_results in enumerate(all_merge_results):
assert all(len(merge_result) == 0 for merge_result in merge_results), (
"Reduce stage did not process outputs from merge tasks at index: "
f"{merge_idx}"
)
self._reduce_results: List[Tuple[int, ObjectRef]] = []
def __iter__(self):
return self
def __next__(self):
if not self._reduce_arg_blocks:
raise StopIteration
reduce_idx, reduce_arg_blocks = self._reduce_arg_blocks.pop(0)
merge_idx = self._stage.merge_schedule.get_merge_idx_for_reducer_idx(reduce_idx)
# Submit one partition of reduce tasks, one for each of the P
# outputs produced by the corresponding merge task.
# We also add the merge task arguments so that the reduce task
# is colocated with its inputs.
block, meta_with_schema = self._shuffle_reduce.options(
**self._ray_remote_args,
**self._stage.get_merge_task_options(merge_idx),
num_returns=2,
).remote(*self._reduce_args, *reduce_arg_blocks, partial_reduce=False)
self._reduce_results.append((reduce_idx, block))
return meta_with_schema
def pop_reduce_results(self):
reduce_results = self._reduce_results
self._reduce_results = []
return reduce_results
class PushBasedShuffleTaskScheduler(ExchangeTaskScheduler):
"""
Push-based shuffle merges intermediate map outputs on the reducer nodes
while other map tasks are executing. The merged outputs are merged again
during a final reduce stage. This works as follows:
1. Submit rounds of concurrent map and merge tasks until all map inputs
have been processed. In each round, we execute:
M map tasks
Each produces N outputs. Each output contains P blocks.
N merge tasks
Takes 1 output from each of M map tasks.
Each produces P outputs.
Where M and N are chosen to maximize parallelism across CPUs. Note that
this assumes that all CPUs in the cluster will be dedicated to the
shuffle job.
Map and merge tasks are pipelined so that we always merge the previous
round of map outputs while executing the next round of map tasks.
2. In the final reduce stage:
R reduce tasks
Takes 1 output from one of the merge tasks from every round.
Notes:
N * P = R = total number of output blocks
M / N = merge factor - the ratio of map : merge tasks is to improve
pipelined parallelism. For example, if map takes twice as long to
execute as merge, then we should set this to 2. If pipeline bubbles
appear and the merge tasks are much longer than the map tasks, then
the merge factor should be decreased, and vice versa.
See paper at https://arxiv.org/abs/2203.05072 for more details.
"""
def execute(
self,
refs: List[RefBundle],
output_num_blocks: int,
task_ctx: TaskContext,
map_ray_remote_args: Optional[Dict[str, Any]] = None,
reduce_ray_remote_args: Optional[Dict[str, Any]] = None,
merge_factor: float = 2,
_debug_limit_execution_to_num_blocks: int = None,
) -> Tuple[List[RefBundle], StatsDict]:
logger.debug("Using experimental push-based shuffle.")
# TODO: Preemptively clear the blocks list since we will incrementally delete
# the last remaining references as we submit the dependent map tasks during the
# map-merge stage.
# TODO(swang): For jobs whose reduce work is heavier than the map work,
# we should support fractional merge factors.
# TODO(swang): For large jobs, we should try to choose the merge factor
# automatically, e.g., by running one test round of map and merge tasks
# and comparing their run times.
# TODO(swang): Add option to automatically reduce write amplification
# during map-merge stage, by limiting how many partitions can be
# processed concurrently.
input_blocks_list = []
for ref_bundle in refs:
input_blocks_list.extend(ref_bundle.block_refs)
input_owned = all(b.owns_blocks for b in refs)
if map_ray_remote_args is None:
map_ray_remote_args = {}
if reduce_ray_remote_args is None:
reduce_ray_remote_args = {}
# The placement strategy for reduce tasks is overwritten to colocate
# them with their inputs from the merge stage, so remove any
# pre-specified scheduling strategy here.
reduce_ray_remote_args = reduce_ray_remote_args.copy()
reduce_ray_remote_args.pop("scheduling_strategy", None)
# Compute all constants used for task scheduling.
num_cpus_per_node_map = _get_num_cpus_per_node_map()
stage = self._compute_shuffle_schedule(
num_cpus_per_node_map,
len(input_blocks_list),
merge_factor,
output_num_blocks,
)
caller_memory_usage = (
stage.get_estimated_num_refs() * CALLER_MEMORY_USAGE_PER_OBJECT_REF
)
self.warn_on_driver_memory_usage(
caller_memory_usage,
"Execution is estimated to use at least "
f"{convert_bytes_to_human_readable_str(caller_memory_usage)}"
" of driver memory. Ensure that the driver machine has at least "
"this much memory to ensure job completion.",
)
# TODO(swang): Use INFO level. Currently there is no easy way to set
# the logging level to DEBUG from a driver script, so just print
# verbosely for now.
# See https://github.com/ray-project/ray/issues/42002.
logger.debug(f"Push-based shuffle schedule:\n{stage}")
map_fn = self._map_partition
merge_fn = self._merge
def map_partition(*args, **kwargs):
return map_fn(self._exchange_spec.map, *args, **kwargs)
def merge(*args, **kwargs):
return merge_fn(self._exchange_spec.reduce, *args, **kwargs)
shuffle_map = cached_remote_fn(map_partition)
shuffle_map = shuffle_map.options(
**map_ray_remote_args,
num_returns=1 + stage.merge_schedule.num_merge_tasks_per_round,
)
if _debug_limit_execution_to_num_blocks is not None:
input_blocks_list = input_blocks_list[:_debug_limit_execution_to_num_blocks]
logger.debug(f"Limiting execution to {len(input_blocks_list)} map tasks")
map_stage_iter = _MapStageIterator(
input_blocks_list,
shuffle_map,
[output_num_blocks, stage.merge_schedule, *self._exchange_spec._map_args],
)
sub_progress_bar_dict = task_ctx.sub_progress_bar_dict
bar_name = ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME
assert bar_name in sub_progress_bar_dict, sub_progress_bar_dict
map_bar = sub_progress_bar_dict[bar_name]
map_stage_executor = _PipelinedStageExecutor(
map_stage_iter, stage.num_map_tasks_per_round, progress_bar=map_bar
)
shuffle_merge = cached_remote_fn(merge)
merge_stage_iter = _MergeStageIterator(
map_stage_iter, shuffle_merge, stage, self._exchange_spec._reduce_args
)
merge_stage_executor = _PipelinedStageExecutor(
merge_stage_iter,
stage.merge_schedule.num_merge_tasks_per_round,
max_concurrent_rounds=2,
)
# Execute the map-merge stage. This submits tasks in rounds of M map
# tasks and N merge tasks each. Task execution between map and merge is
# pipelined, so that while executing merge for one round of inputs, we
# also execute the map tasks for the following round.
map_done = False
merge_done = False
map_stage_metadata_schema = []
merge_stage_metadata_schema = []
while not (map_done and merge_done):
try:
map_stage_metadata_schema += next(map_stage_executor)
except StopIteration:
map_done = True
break
try:
merge_stage_metadata_schema += next(merge_stage_executor)
except StopIteration:
merge_done = True
break
self.warn_on_high_local_memory_store_usage()
all_merge_results = merge_stage_iter.pop_merge_results()
if _debug_limit_execution_to_num_blocks is not None:
for merge_idx in range(len(all_merge_results)):
while len(all_merge_results[merge_idx]) < stage.num_rounds:
# Repeat the first merge task's results.
all_merge_results[merge_idx].append(
all_merge_results[merge_idx][0][:]
)
# Execute and wait for the reduce stage.
bar_name = ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME
assert bar_name in sub_progress_bar_dict, sub_progress_bar_dict
reduce_bar = sub_progress_bar_dict[bar_name]
shuffle_reduce = cached_remote_fn(self._exchange_spec.reduce)
reduce_stage_iter = _ReduceStageIterator(
stage,
shuffle_reduce,
all_merge_results,
reduce_ray_remote_args,
self._exchange_spec._reduce_args,
_debug_limit_execution_to_num_blocks,
)
max_reduce_tasks_in_flight = output_num_blocks
ctx = DataContext.get_current()
if ctx.pipeline_push_based_shuffle_reduce_tasks:
# If pipelining is enabled, we should still try to utilize all
# cores.
max_reduce_tasks_in_flight = min(
max_reduce_tasks_in_flight, sum(num_cpus_per_node_map.values())
)
reduce_stage_executor = _PipelinedStageExecutor(
reduce_stage_iter,
max_reduce_tasks_in_flight,
max_concurrent_rounds=2,
progress_bar=reduce_bar,
)
reduce_stage_metadata_schema = []
while True:
try:
reduce_stage_metadata_schema += next(reduce_stage_executor)
except StopIteration:
break
self.warn_on_high_local_memory_store_usage()
new_blocks = reduce_stage_iter.pop_reduce_results()
sorted_blocks = [
(block[0], block[1], reduce_stage_metadata_schema[i])
for i, block in enumerate(new_blocks)
]
sorted_blocks.sort(key=lambda x: x[0])
new_blocks, reduce_stage_metadata_schema = [], []
if sorted_blocks:
res: Tuple[
List[Any], List[ObjectRef[Block]], List[BlockMetadataWithSchema]
] = unzip(sorted_blocks)
_, new_blocks, reduce_stage_metadata_schema = res
del sorted_blocks
if _debug_limit_execution_to_num_blocks is not None:
output_num_blocks = min(
_debug_limit_execution_to_num_blocks, output_num_blocks
)
assert (
len(new_blocks) == output_num_blocks
), f"Expected {output_num_blocks} outputs, produced {len(new_blocks)}"
output = []
for block, meta_with_schema in zip(new_blocks, reduce_stage_metadata_schema):
output.append(
RefBundle(
[BlockEntry(block, meta_with_schema.metadata)],
owns_blocks=input_owned,
schema=meta_with_schema.schema,
)
)
stats = {
"map": to_stats(map_stage_metadata_schema),
"merge": to_stats(merge_stage_metadata_schema),
"reduce": to_stats(reduce_stage_metadata_schema),
}
return (output, stats)
@staticmethod
def _map_partition(
map_fn,
idx: int,
block: Block,
output_num_blocks: int,
schedule: _MergeTaskSchedule,
*map_args: List[Any],
) -> List[Union[Block, "BlockMetadataWithSchema"]]:
mapper_outputs = map_fn(idx, block, output_num_blocks, *map_args)
# A merge task may produce results for multiple downstream reducer
# tasks. Therefore, each map task should give each merge task a
# partition of its outputs, where the length of the partition is equal
# to the number of reducers downstream to the merge task.
partition = []
merge_idx = 0
while merge_idx < schedule.num_merge_tasks_per_round and mapper_outputs:
output = mapper_outputs.pop(0)
partition.append(output)
if len(partition) == schedule.get_num_reducers_per_merge_idx(merge_idx):
yield partition
partition = []
merge_idx += 1
assert not partition
assert len(mapper_outputs) == 1, (
mapper_outputs,
"The last output should be a BlockMetadataWithSchema",
)
assert isinstance(mapper_outputs[0], BlockMetadataWithSchema)
yield mapper_outputs[0]
assert merge_idx == schedule.num_merge_tasks_per_round, (
merge_idx,
schedule.num_merge_tasks_per_round,
)
@staticmethod
def _merge(
reduce_fn,
*all_mapper_outputs: List[List[Block]],
reduce_args: Optional[List[Any]] = None,
) -> List[Union["BlockMetadataWithSchema", Block]]:
"""
Returns list of [BlockMetadata, O1, O2, O3, ...output_num_blocks].
"""
assert (
len({len(mapper_outputs) for mapper_outputs in all_mapper_outputs}) == 1
), "Received different number of map inputs"
stats = BlockExecStats.builder()
if not reduce_args:
reduce_args = []
num_rows = 0
size_bytes = 0
schemas = []
for i, mapper_outputs in enumerate(zip(*all_mapper_outputs)):
block_meta_with_schema: Tuple[Block, "BlockMetadataWithSchema"] = reduce_fn(
*reduce_args, *mapper_outputs, partial_reduce=True
)
block, meta_with_schema = block_meta_with_schema
yield block
block = BlockAccessor.for_block(block)
num_rows += block.num_rows()
size_bytes += block.size_bytes()
del block
schemas.append(meta_with_schema.schema)
schema = _take_first_non_empty_schema(iter(schemas))
meta = BlockMetadata(
num_rows=num_rows,
size_bytes=size_bytes,
input_files=None,
exec_stats=stats.build(),
)
meta_with_schema = BlockMetadataWithSchema.from_metadata(meta, schema=schema)
yield meta_with_schema
@staticmethod
def _compute_shuffle_schedule(
num_cpus_per_node_map: Dict[str, int],
num_input_blocks: int,
merge_factor: float,
num_output_blocks: int,
) -> _PushBasedShuffleStage:
num_cpus_total = sum(v for v in num_cpus_per_node_map.values())
logger.debug(
f"Found {num_cpus_total} CPUs available CPUs for push-based shuffle."
)
num_tasks_per_map_merge_group = merge_factor + 1
num_total_merge_tasks = math.ceil(num_input_blocks / merge_factor)
num_merge_tasks_per_round = 0
merge_task_placement = []
leftover_cpus = 0
# Compute the total number of merge tasks and their node placement.
# Each merge task should be grouped with `merge_factor` map tasks for
# pipelining. These groups should then be spread across nodes according
# to CPU availability for load-balancing.
num_input_blocks_remaining = num_input_blocks
for node, num_cpus in num_cpus_per_node_map.items():
# First find how many merge tasks we should run on this node.
# We take the min of the number of CPUs on this node and the number
# of input blocks that we haven't scheduled yet, in case there are
# fewer input blocks than CPU slots on this node.
num_cpu_slots = min(num_cpus, num_input_blocks_remaining)
num_merge_tasks_on_cur_node = round(
num_cpu_slots / num_tasks_per_map_merge_group
)
# For small datasets, the number of tasks to run may be less than
# the total CPU slots available.
num_merge_tasks_on_cur_node = min(
num_merge_tasks_on_cur_node, num_total_merge_tasks
)
for i in range(num_merge_tasks_on_cur_node):
merge_task_placement.append(node)
# We schedule `merge_factor` many map tasks for every merge
# task. Subtract from the number of input blocks remaining to
# account for cases where the number of map tasks is smaller
# than the available CPU slots.
num_input_blocks_remaining -= merge_factor
num_cpus -= num_tasks_per_map_merge_group
num_merge_tasks_per_round += num_merge_tasks_on_cur_node
# Handle the case where a single node cannot fit a group of map and
# merge tasks, but we can spread the group across multiple distinct
# nodes.
leftover_cpus += num_cpus
if (
leftover_cpus >= num_tasks_per_map_merge_group
and num_merge_tasks_per_round < num_total_merge_tasks
):
merge_task_placement.append(node)
num_merge_tasks_per_round += 1
leftover_cpus -= num_tasks_per_map_merge_group
num_input_blocks_remaining -= merge_factor
num_input_blocks_remaining = max(0, num_input_blocks_remaining)
if num_merge_tasks_per_round == 0:
# For small datasets, make sure we have at least one merge task.
for node, num_cpus in num_cpus_per_node_map.items():
if num_cpus >= 1:
merge_task_placement.append(node)
num_merge_tasks_per_round = 1
break
assert num_merge_tasks_per_round == len(merge_task_placement)
assert num_merge_tasks_per_round > 0, num_merge_tasks_per_round
# Use the remaining CPUs to execute map tasks.
num_map_tasks_per_round = num_cpus_total - num_merge_tasks_per_round
num_map_tasks_per_round = min(num_map_tasks_per_round, num_input_blocks)
# Make sure there is at least one map task in each round.
num_map_tasks_per_round = max(num_map_tasks_per_round, 1)
num_rounds = math.ceil(num_input_blocks / num_map_tasks_per_round)
return _PushBasedShuffleStage(
num_output_blocks,
num_rounds,
num_map_tasks_per_round,
merge_task_placement,
)
def _get_num_cpus_per_node_map() -> Dict[str, int]:
total_resources_by_node = ray.state.total_resources_per_node()
# Map from per-node resource name to number of CPUs available on that
# node.
num_cpus_per_node_map = {}
for node_id, resources in total_resources_by_node.items():
num_cpus = int(resources.get("CPU", 0))
if num_cpus == 0:
continue
num_cpus_per_node_map[node_id] = num_cpus
return num_cpus_per_node_map
@@ -0,0 +1,152 @@
import logging
import math
from typing import Callable, Iterable, List, Optional, Tuple, Union
import numpy as np
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.execution.interfaces.task_context import TaskContext
from ray.data._internal.planner.exchange.interfaces import ExchangeTaskSpec
from ray.data.block import (
Block,
BlockAccessor,
BlockExecStats,
BlockMetadata,
BlockMetadataWithSchema,
)
from ray.data.context import MAX_SAFE_BLOCK_SIZE_FACTOR
logger = logging.getLogger(__name__)
class ShuffleTaskSpec(ExchangeTaskSpec):
"""
The implementation for shuffle tasks.
This is used by random_shuffle() and repartition().
"""
SPLIT_REPARTITION_SUB_PROGRESS_BAR_NAME = "Split Repartition"
def __init__(
self,
target_shuffle_max_block_size: int,
random_shuffle: bool = False,
random_seed: Optional[int] = None,
upstream_map_fn: Optional[Callable[[Iterable[Block]], Iterable[Block]]] = None,
):
super().__init__(
map_args=[
target_shuffle_max_block_size,
upstream_map_fn,
random_shuffle,
random_seed,
],
reduce_args=[random_shuffle, random_seed],
)
@staticmethod
def map(
idx: int,
block: Block,
output_num_blocks: int,
target_shuffle_max_block_size: int,
upstream_map_fn: Optional[Callable[[Iterable[Block]], Iterable[Block]]],
random_shuffle: bool,
random_seed: Optional[int],
) -> List[Union[Block, "BlockMetadataWithSchema"]]:
stats = BlockExecStats.builder()
if upstream_map_fn:
# Create a local TaskContext for the upstream map function.
# May be used by expressions that depend on task-level state.
local_ctx = TaskContext(task_idx=idx, op_name="shuffle_map")
with TaskContext.current(local_ctx):
# TODO: Support dynamic block splitting in
# all-to-all ops, to avoid having to re-fuse
# upstream blocks together.
upstream_map_iter = upstream_map_fn([block])
mapped_block = next(upstream_map_iter)
builder = BlockAccessor.for_block(mapped_block).builder()
builder.add_block(mapped_block)
for mapped_block in upstream_map_iter:
builder.add_block(mapped_block)
# Drop the upstream inputs to reduce memory usage.
del mapped_block
block = builder.build()
block = BlockAccessor.for_block(block)
if (
block.size_bytes()
> MAX_SAFE_BLOCK_SIZE_FACTOR * target_shuffle_max_block_size
):
logger.warning(
"Input block to map task has size "
f"{block.size_bytes() // (1024 * 1024)}MiB, which exceeds "
"DataContext.get_current().target_shuffle_max_block_size="
f"{target_shuffle_max_block_size // (1024 * 1024)}MiB. "
"This can lead to out-of-memory errors and can happen "
"when map tasks are fused to the shuffle operation. "
"To prevent fusion, call Dataset.materialize() on the "
"dataset before shuffling."
)
# Randomize the distribution of records to blocks.
if random_shuffle:
seed_i = random_seed + idx if random_seed is not None else None
block = block.random_shuffle(seed_i)
block = BlockAccessor.for_block(block)
# Build a list of slices to return. It's okay to put the results in a
# list instead of yielding them as a generator because slicing the
# ArrowBlock is zero-copy.
slice_sz = max(1, math.ceil(block.num_rows() / output_num_blocks))
slices = []
for i in range(output_num_blocks):
slices.append(block.slice(i * slice_sz, (i + 1) * slice_sz))
# Randomize the distribution order of the blocks (this prevents empty
# outputs when input blocks are very small).
if random_shuffle:
random = np.random.RandomState(seed_i)
random.shuffle(slices)
num_rows = sum(BlockAccessor.for_block(s).num_rows() for s in slices)
assert num_rows == block.num_rows(), (num_rows, block.num_rows())
from ray.data.block import BlockMetadataWithSchema
meta = block.get_metadata(block_exec_stats=stats.build())
schema = block.schema()
meta_with_schema = BlockMetadataWithSchema.from_metadata(meta, schema=schema)
return slices + [meta_with_schema]
@staticmethod
def reduce(
random_shuffle: bool,
random_seed: Optional[int],
*mapper_outputs: List[Block],
partial_reduce: bool = False,
) -> Tuple[Block, "BlockMetadataWithSchema"]:
# TODO: Support fusion with other downstream operators.
stats = BlockExecStats.builder()
builder = DelegatingBlockBuilder()
for block in mapper_outputs:
builder.add_block(block)
new_block = builder.build()
accessor = BlockAccessor.for_block(new_block)
if random_shuffle:
new_block = accessor.random_shuffle(
random_seed if random_seed is not None else None
)
accessor = BlockAccessor.for_block(new_block)
new_metadata = BlockMetadata(
num_rows=accessor.num_rows(),
size_bytes=accessor.size_bytes(),
input_files=None,
exec_stats=stats.build(),
)
from ray.data.block import BlockMetadataWithSchema
meta_with_schema = BlockMetadataWithSchema.from_metadata(
new_metadata, schema=accessor.schema()
)
return new_block, meta_with_schema
@@ -0,0 +1,240 @@
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypeVar, Union
import numpy as np
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.planner.exchange.interfaces import ExchangeTaskSpec
from ray.data._internal.progress.progress_bar import ProgressBar
from ray.data._internal.remote_fn import cached_remote_fn
from ray.data._internal.table_block import TableBlockAccessor
from ray.data._internal.util import NULL_SENTINEL
from ray.data.block import Block, BlockAccessor, BlockExecStats
from ray.types import ObjectRef
T = TypeVar("T")
if TYPE_CHECKING:
import pyarrow
from ray.data.block import BlockMetadataWithSchema
class SortKey:
"""SortKey class to convert between different sort args formats."""
def __init__(
self,
key: Optional[Union[str, List[str]]] = None,
descending: Union[bool, List[bool]] = False,
boundaries: Optional[List[T]] = None,
):
if key is None:
key = []
if isinstance(key, str):
key = [key]
if not (isinstance(key, list) and all(isinstance(k, str) for k in key)):
raise ValueError(
f"Key must be a string or a list of strings, but got {key}."
)
if isinstance(descending, bool):
descending = [descending for _ in key]
elif isinstance(descending, list):
if len(descending) != len(key):
raise ValueError(
"Length of `descending` does not match the length of the key."
)
self._columns = key
self._descending = descending
if boundaries:
for item in boundaries:
if not isinstance(item, (int, float)):
raise ValueError(
"The type of items in boundaries must be int or float."
)
boundaries = list(set(boundaries))
boundaries.sort()
self._boundaries = boundaries
def get_columns(self) -> List[str]:
return self._columns
def get_descending(self) -> List[bool]:
return self._descending
def to_arrow_sort_args(self) -> List[Tuple[str, str]]:
return [
(key, "descending" if desc else "ascending")
for key, desc in zip(self._columns, self._descending)
]
def to_pandas_sort_args(self) -> Tuple[List[str], List[bool]]:
return self._columns, [not desc for desc in self._descending]
def validate_schema(self, schema: Optional[Union[type, "pyarrow.lib.Schema"]]):
"""Check the key function is valid on the given schema."""
if schema is None:
# Dataset is empty/cleared, validation not possible.
return
if self._columns and len(schema.names) > 0:
schema_names_set = set(schema.names)
for column in self._columns:
if column not in schema_names_set:
raise ValueError(
f"You specified the column '{column}', but there's no such "
"column in the dataset. The dataset has columns: "
f"{schema.names}"
)
@property
def boundaries(self):
return self._boundaries
class SortTaskSpec(ExchangeTaskSpec):
"""
The implementation for distributed sort tasks.
The algorithm is similar to [External Merge Sort]
(https://en.wikipedia.org/wiki/External_sorting).
Sorting is done in 3 steps: sampling, sorting individual blocks, and
merging sorted blocks.
Sampling (`sample_boundaries`): we get a number of sample items from each block,
sort them, and use them to compute boundaries that would partition all items into
approximately equal ranges.
Sorting (`map`): each block is sorted locally, then partitioned into smaller
blocks according to the boundaries. Each partitioned block is passed to a merge
task.
Merging (`reduce`): a merge task would receive a block from every worker that
consists of items in a certain range. It then merges the sorted blocks into one
sorted block and becomes part of the new, sorted block.
"""
SORT_SAMPLE_SUB_PROGRESS_BAR_NAME = "Sort Sample"
def __init__(
self,
boundaries: List[T],
sort_key: SortKey,
):
super().__init__(
map_args=[boundaries, sort_key],
reduce_args=[sort_key],
)
@staticmethod
def map(
idx: int,
block: Block,
output_num_blocks: int,
boundaries: List[T],
sort_key: SortKey,
) -> List[Union[Block, "BlockMetadataWithSchema"]]:
stats = BlockExecStats.builder()
accessor = BlockAccessor.for_block(block)
out = accessor.sort_and_partition(boundaries, sort_key)
from ray.data.block import BlockMetadataWithSchema
meta_with_schema = BlockMetadataWithSchema.from_block(
block, block_exec_stats=stats.build()
)
return out + [meta_with_schema]
@staticmethod
def reduce(
sort_key: SortKey,
*mapper_outputs: List[Block],
partial_reduce: bool = False,
) -> Tuple[Block, "BlockMetadataWithSchema"]:
normalized_blocks = TableBlockAccessor.normalize_block_types(
mapper_outputs,
target_block_type=None,
)
blocks, meta_with_schema = BlockAccessor.for_block(
normalized_blocks[0]
).merge_sorted_blocks(normalized_blocks, sort_key)
return blocks, meta_with_schema
@staticmethod
def sample_boundaries(
blocks: List[ObjectRef[Block]],
sort_key: SortKey,
num_reducers: int,
sample_bar: Optional[ProgressBar] = None,
label_selector: Optional[Dict[str, str]] = None,
) -> List[T]:
"""
Return (num_reducers - 1) items in ascending order from the blocks that
partition the domain into ranges with approximately equally many elements.
Each boundary item is a tuple of a form (col1_value, col2_value, ...).
"""
columns = sort_key.get_columns()
n_samples = int(num_reducers * 10 / len(blocks))
sample_block = cached_remote_fn(_sample_block)
if label_selector:
sample_block = sample_block.options(label_selector=label_selector)
sample_results = [
sample_block.remote(block, n_samples, sort_key) for block in blocks
]
if sample_bar is None:
sample_bar = ProgressBar(
SortTaskSpec.SORT_SAMPLE_SUB_PROGRESS_BAR_NAME,
len(blocks) * n_samples,
unit="rows",
)
# TODO(zhilong): Update sort sample bar before finished.
samples = sample_bar.fetch_until_complete(sample_results)
del sample_results
samples: List[Block] = [s for s in samples if len(s) > 0]
# The dataset is empty
if len(samples) == 0:
return [None] * (num_reducers - 1)
# Convert samples to a sorted list[tuple[...]] where each tuple represents a
# sample.
# TODO: Once we deprecate pandas blocks, we can avoid this conversion and
# directly sort the samples.
builder = DelegatingBlockBuilder()
for sample in samples:
builder.add_block(sample)
samples_table = builder.build()
samples_dict = BlockAccessor.for_block(samples_table).to_numpy(columns=columns)
# This zip does the transposition from list of column values to list of tuples.
samples_list = list(zip(*samples_dict.values()))
def is_na(x):
# Check if x is None or NaN. Type casting to np.array first to avoid
# isnan failing on strings and other types.
if x is None:
return True
x = np.asarray(x)
if np.issubdtype(x.dtype, np.number):
return np.isnan(x)
return False
# To allow multi-directional sort, we utilize Python's stable sort: we
# sort several times with different directions. We do this in reverse, so
# that the last key we sort by is the primary sort key passed by the user.
for i, desc in list(enumerate(sort_key.get_descending()))[::-1]:
# Sort the list, but Nones should be NULL_SENTINEL to ensure safe sorting.
samples_list.sort(
key=lambda sample: NULL_SENTINEL if is_na(sample[i]) else sample[i],
reverse=desc,
)
# Each boundary corresponds to a quantile of the data.
quantile_indices = [
int(q * (len(samples_list) - 1))
for q in np.linspace(0, 1, num_reducers + 1)
]
# Exclude the first and last quantiles because they're 0 and 1.
return [samples_list[i] for i in quantile_indices[1:-1]]
def _sample_block(block: Block, n_samples: int, sort_key: SortKey) -> Block:
return BlockAccessor.for_block(block).sample(n_samples, sort_key)
@@ -0,0 +1,171 @@
from typing import Any, Dict, List, Optional, Tuple
import ray
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle, TaskContext
from ray.data._internal.execution.interfaces.transform_fn import (
AllToAllTransformFnResult,
)
from ray.data._internal.planner.exchange.interfaces import (
ExchangeTaskScheduler,
)
from ray.data._internal.planner.exchange.shuffle_task_spec import ShuffleTaskSpec
from ray.data._internal.remote_fn import cached_remote_fn
from ray.data._internal.split import _split_at_indices
from ray.data._internal.util import unzip
from ray.data.block import (
Block,
BlockMetadata,
BlockMetadataWithSchema,
)
from ray.types import ObjectRef
class SplitRepartitionTaskScheduler(ExchangeTaskScheduler):
"""
The split (non-shuffle) repartition scheduler.
First, we calculate global splits needed to produce `output_num_blocks` blocks.
After the split blocks are generated accordingly, reduce tasks are scheduled
to combine split blocks together.
"""
def execute(
self,
refs: List[RefBundle],
output_num_blocks: int,
ctx: TaskContext,
map_ray_remote_args: Optional[Dict[str, Any]] = None,
reduce_ray_remote_args: Optional[Dict[str, Any]] = None,
) -> AllToAllTransformFnResult:
input_num_rows = 0
input_owned_by_consumer = True
for ref_bundle in refs:
block_num_rows = ref_bundle.num_rows()
if block_num_rows is None:
raise ValueError(
"Cannot split partition on blocks with unknown number of rows."
)
input_num_rows += block_num_rows
if not ref_bundle.owns_blocks:
input_owned_by_consumer = False
# Compute the (output_num_blocks) indices needed for an equal split of the
# input blocks. When output_num_blocks=1, the total number of
# input rows is used as the end index during the split calculation,
# so that we can combine all input blocks into a single output block.
indices = []
if output_num_blocks == 1:
indices = [input_num_rows]
else:
cur_idx = 0
for _ in range(output_num_blocks - 1):
cur_idx += input_num_rows / output_num_blocks
indices.append(int(cur_idx))
assert len(indices) <= output_num_blocks, (indices, output_num_blocks)
if map_ray_remote_args is None:
map_ray_remote_args = {}
if reduce_ray_remote_args is None:
reduce_ray_remote_args = {}
if "scheduling_strategy" not in reduce_ray_remote_args:
reduce_ray_remote_args = reduce_ray_remote_args.copy()
reduce_ray_remote_args["scheduling_strategy"] = "SPREAD"
blocks_with_metadata: List[Tuple[ObjectRef[Block], BlockMetadata]] = []
for ref_bundle in refs:
blocks_with_metadata.extend(
(entry.ref, entry.metadata) for entry in ref_bundle.blocks
)
split_return = _split_at_indices(
blocks_with_metadata,
indices,
input_owned_by_consumer,
label_selector=map_ray_remote_args.get("label_selector"),
)
split_block_refs, split_metadata = [], []
for b, m in zip(*split_return):
split_block_refs.append(b)
split_metadata.extend(m)
sub_progress_bar_dict = ctx.sub_progress_bar_dict
bar_name = ShuffleTaskSpec.SPLIT_REPARTITION_SUB_PROGRESS_BAR_NAME
assert bar_name in sub_progress_bar_dict, sub_progress_bar_dict
reduce_bar = sub_progress_bar_dict[bar_name]
reduce_task = cached_remote_fn(self._exchange_spec.reduce)
reduce_return = [
reduce_task.options(**reduce_ray_remote_args, num_returns=2).remote(
*self._exchange_spec._reduce_args,
*split_block_refs[j],
)
for j in range(output_num_blocks)
# Only process splits which contain blocks.
if len(split_block_refs[j]) > 0
]
reduce_block_refs, reduce_metadata_schema = [], []
if reduce_return:
reduce_block_refs, reduce_metadata_schema = unzip(reduce_return)
reduce_metadata_schema: List[
"BlockMetadataWithSchema"
] = reduce_bar.fetch_until_complete(list(reduce_metadata_schema))
reduce_block_refs = list(reduce_block_refs)
# Handle empty blocks.
if len(reduce_block_refs) < output_num_blocks:
import pyarrow as pa
from ray.data._internal.arrow_block import ArrowBlockBuilder
from ray.data._internal.pandas_block import (
PandasBlockBuilder,
PandasBlockSchema,
)
num_empty_blocks = output_num_blocks - len(reduce_block_refs)
if len(reduce_metadata_schema) > 0:
first_block_schema = reduce_metadata_schema[0].schema
if isinstance(first_block_schema, pa.Schema):
builder = ArrowBlockBuilder()
elif isinstance(first_block_schema, PandasBlockSchema):
builder = PandasBlockBuilder()
else:
raise ValueError(
"Cannot split partition on blocks with unknown block schema:"
f" {first_block_schema}."
)
else:
# If the result is empty, default to Arrow format for the empty blocks.
builder = ArrowBlockBuilder()
empty_block = builder.build()
empty_meta_with_schema = BlockMetadataWithSchema.from_block(
empty_block
) # No stats for empty block.
empty_block_refs, empty_metadata = zip(
*[
(ray.put(empty_block), empty_meta_with_schema)
for _ in range(num_empty_blocks)
]
)
reduce_block_refs.extend(empty_block_refs)
reduce_metadata_schema.extend(empty_metadata)
output = []
assert len(reduce_block_refs) == len(reduce_metadata_schema), (
len(reduce_block_refs),
len(reduce_metadata_schema),
)
for block, meta_with_schema in zip(reduce_block_refs, reduce_metadata_schema):
output.append(
RefBundle(
[BlockEntry(block, meta_with_schema.metadata)],
owns_blocks=input_owned_by_consumer,
schema=meta_with_schema.schema,
)
)
stats = {
"split": split_metadata,
"reduce": reduce_metadata_schema,
}
return (output, stats)