import warnings from functools import partial from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Type, TypeVar if TYPE_CHECKING: import pyarrow.fs from ray.data._internal.execution.execution_callback import ExecutionCallback from ray.data._internal.execution.interfaces import PhysicalOperator from ray.data._internal.execution.operators.aggregate_num_rows import ( AggregateNumRows, ) from ray.data._internal.execution.operators.hash_shuffle_v2 import ( _SHUFFLE_MAP_RUNTIME_ENV, _make_hash_partition_fn, ) from ray.data._internal.execution.operators.input_data_buffer import ( InputDataBuffer, ) from ray.data._internal.execution.operators.join import ( JoinOperator, _make_join_reduce_fn, ) from ray.data._internal.execution.operators.limit_operator import LimitOperator from ray.data._internal.execution.operators.mix_operator import MixOperator from ray.data._internal.execution.operators.output_splitter import OutputSplitter from ray.data._internal.execution.operators.shuffle_operators.shuffle_map_operator import ( ShuffleMapOp, ) from ray.data._internal.execution.operators.shuffle_operators.shuffle_reduce_operator import ( ShuffleReduceOp, ) from ray.data._internal.execution.operators.union_operator import UnionOperator from ray.data._internal.execution.operators.zip_operator import ZipOperator from ray.data._internal.logical.interfaces import ( LogicalOperator, LogicalPlan, PhysicalPlan, ) from ray.data._internal.logical.operators import ( AbstractAllToAll, AbstractFrom, AbstractUDFMap, Count, Download, Filter, InputData, Join, JoinType, Limit, ListFiles, Mix, Project, Read, ReadFiles, StreamingRepartition, StreamingSplit, Union, Write, Zip, ) from ray.data._internal.planner.checkpoint import ( plan_read_files_op_with_checkpoint_filter, plan_read_op_with_checkpoint_filter, plan_write_op_with_checkpoint_writer, ) from ray.data._internal.planner.plan_all_to_all_op import plan_all_to_all_op from ray.data._internal.planner.plan_download_op import plan_download_op from ray.data._internal.planner.plan_list_files_op import plan_list_files_op from ray.data._internal.planner.plan_read_files_op import plan_read_files_op from ray.data._internal.planner.plan_read_op import plan_read_op from ray.data._internal.planner.plan_udf_map_op import ( plan_filter_op, plan_project_op, plan_streaming_repartition_op, plan_udf_map_op, ) from ray.data._internal.planner.plan_write_op import plan_write_op from ray.data._internal.usage import create_usage_callback from ray.data.checkpoint.load_checkpoint_callback import LoadCheckpointCallback from ray.data.context import DataContext from ray.data.datasource.file_datasink import _FileDatasink LogicalOperatorType = TypeVar("LogicalOperatorType", bound=LogicalOperator) PlanLogicalOpFn = Callable[ [LogicalOperatorType, List[PhysicalOperator], DataContext], PhysicalOperator ] def plan_input_data_op( logical_op: InputData, physical_children: List[PhysicalOperator], data_context: DataContext, ) -> PhysicalOperator: """Get the corresponding DAG of physical operators for InputData.""" assert len(physical_children) == 0 return InputDataBuffer( data_context, input_data=logical_op.input_data, ) def plan_from_op( op: AbstractFrom, physical_children: List[PhysicalOperator], data_context: DataContext, ) -> PhysicalOperator: assert len(physical_children) == 0 return InputDataBuffer(data_context, op.input_data) def plan_zip_op(_, physical_children, data_context): assert len(physical_children) >= 2 return ZipOperator(data_context, *physical_children) def plan_mix_op(logical_op, physical_children, data_context): assert len(physical_children) >= 1 return MixOperator( data_context, *physical_children, weights=logical_op.weights, stopping_condition=logical_op.stopping_condition, ) def plan_union_op(_, physical_children, data_context): assert len(physical_children) >= 2 return UnionOperator(data_context, *physical_children) def plan_limit_op(logical_op, physical_children, data_context): assert len(physical_children) == 1 return LimitOperator(logical_op.limit, physical_children[0], data_context) def plan_count_op(logical_op, physical_children, data_context): assert len(physical_children) == 1 return AggregateNumRows( [physical_children[0]], data_context, column_name=Count.COLUMN_NAME ) def _plan_join_shuffle_v2( logical_op: Join, physical_children: List[PhysicalOperator], data_context: DataContext, ) -> PhysicalOperator: left_keys = list(logical_op.left_key_columns) right_keys = list(logical_op.right_key_columns) num_partitions = logical_op.num_partitions join_type = JoinType(logical_op.join_type) left_map = ShuffleMapOp( physical_children[0], data_context, num_partitions=num_partitions, partition_fn=_make_hash_partition_fn(left_keys, num_partitions), map_runtime_env=_SHUFFLE_MAP_RUNTIME_ENV, name=f"JoinShuffleMapLeft(keys={tuple(left_keys)}, parts={num_partitions})", ) right_map = ShuffleMapOp( physical_children[1], data_context, num_partitions=num_partitions, partition_fn=_make_hash_partition_fn(right_keys, num_partitions), map_runtime_env=_SHUFFLE_MAP_RUNTIME_ENV, name=f"JoinShuffleMapRight(keys={tuple(right_keys)}, parts={num_partitions})", ) reduce_fn = _make_join_reduce_fn( join_type=join_type, left_key_col_names=tuple(left_keys), right_key_col_names=tuple(right_keys), left_columns_suffix=logical_op.left_columns_suffix, right_columns_suffix=logical_op.right_columns_suffix, left_schema=logical_op.input_dependencies[0].infer_schema(), right_schema=logical_op.input_dependencies[1].infer_schema(), ) return ShuffleReduceOp( [left_map, right_map], data_context, num_partitions=num_partitions, reduce_fn=reduce_fn, disallow_block_splitting=False, reduce_ray_remote_args=logical_op.aggregator_ray_remote_args, name=f"JoinShuffleReduce(num_partitions={num_partitions})", ) def plan_join_op( logical_op: Join, physical_children: List[PhysicalOperator], data_context: DataContext, ) -> PhysicalOperator: assert len(physical_children) == 2 if data_context.use_hash_shuffle_v2: return _plan_join_shuffle_v2(logical_op, physical_children, data_context) return JoinOperator( data_context=data_context, left_input_op=physical_children[0], right_input_op=physical_children[1], join_type=logical_op.join_type, left_key_columns=logical_op.left_key_columns, right_key_columns=logical_op.right_key_columns, left_columns_suffix=logical_op.left_columns_suffix, right_columns_suffix=logical_op.right_columns_suffix, num_partitions=logical_op.num_outputs, partition_size_hint=logical_op.partition_size_hint, aggregator_ray_remote_args_override=logical_op.aggregator_ray_remote_args, ) def plan_streaming_split_op( logical_op: StreamingSplit, physical_children: List[PhysicalOperator], data_context: DataContext, ): assert len(physical_children) == 1 return OutputSplitter( physical_children[0], n=logical_op.num_splits, equal=logical_op.equal, data_context=data_context, locality_hints=logical_op.locality_hints, ) class Planner: """The planner to convert optimized logical to physical operators. Note that planner is only doing operators conversion. Physical optimization work is done by physical optimizer. """ _DEFAULT_PLAN_FNS = { Read: plan_read_op, ReadFiles: plan_read_files_op, ListFiles: plan_list_files_op, InputData: plan_input_data_op, Write: plan_write_op, AbstractFrom: plan_from_op, Filter: plan_filter_op, AbstractUDFMap: plan_udf_map_op, AbstractAllToAll: plan_all_to_all_op, Mix: plan_mix_op, Union: plan_union_op, Zip: plan_zip_op, Limit: plan_limit_op, Count: plan_count_op, Project: plan_project_op, StreamingRepartition: plan_streaming_repartition_op, Join: plan_join_op, StreamingSplit: plan_streaming_split_op, Download: plan_download_op, } # Operators that support checkpoint filtering. Subclasses can override. _CHECKPOINT_FILTER_OPS = (Read, ReadFiles) def __init__(self): self._supports_checkpointing = False self._plan_fns_for_checkpointing = {} def plan( self, logical_plan: LogicalPlan ) -> Tuple[PhysicalPlan, List["ExecutionCallback"]]: """Convert logical to physical operators recursively in post-order.""" checkpoint_config = logical_plan.context.checkpoint_config callbacks = [cls() for cls in logical_plan.context.execution_callback_classes] callbacks.append(create_usage_callback(logical_plan)) if checkpoint_config is not None and self._check_supports_checkpointing( logical_plan ): self._supports_checkpointing = True data_file_dir, data_file_fs = self._get_data_file_info(logical_plan) checkpoint_callback = self._create_checkpoint_callback( checkpoint_config, ) callbacks.append(checkpoint_callback) # Dynamically set the plan functions for checkpointing because they # need to a reference to the checkpoint ref. self._plan_fns_for_checkpointing = self._get_plan_fns_for_checkpointing( data_file_dir, data_file_fs ) elif checkpoint_config is not None: assert not self._check_supports_checkpointing(logical_plan) warnings.warn( "You've enabled checkpointing, but the logical plan doesn't support " "checkpointing. Checkpointing will be disabled." ) physical_dag, op_map = self._plan_recursively( logical_plan.dag, logical_plan.context ) physical_plan = PhysicalPlan(physical_dag, op_map, logical_plan.context) return physical_plan, callbacks def get_plan_fn(self, logical_op: LogicalOperator) -> PlanLogicalOpFn: if self._supports_checkpointing: assert self._plan_fns_for_checkpointing plan_fn = find_plan_fn(logical_op, self._plan_fns_for_checkpointing) if plan_fn is not None: return plan_fn plan_fn = find_plan_fn(logical_op, self._DEFAULT_PLAN_FNS) if plan_fn is not None: return plan_fn raise ValueError( f"Found unknown logical operator during planning: {logical_op}" ) def _plan_recursively( self, logical_op: LogicalOperator, data_context: DataContext ) -> Tuple[PhysicalOperator, Dict[LogicalOperator, PhysicalOperator]]: """Plan a logical operator and its input dependencies recursively. Args: logical_op: The logical operator to plan. data_context: The data context. Returns: A tuple of the physical operator corresponding to the logical operator, and a mapping from physical to logical operators. """ op_map: Dict[PhysicalOperator, LogicalOperator] = {} # Plan the input dependencies first. physical_children = [] for child in logical_op.input_dependencies: physical_child, child_op_map = self._plan_recursively(child, data_context) physical_children.append(physical_child) op_map.update(child_op_map) plan_fn = self.get_plan_fn(logical_op) # We will call `set_logical_operators()` in the following for-loop, # no need to do it here. physical_op = plan_fn(logical_op, physical_children, data_context) # Traverse up the DAG, and set the mapping from physical to logical operators. # At this point, all physical operators without logical operators set # must have been created by the current logical operator. queue = [physical_op] while queue: curr_physical_op = queue.pop() if curr_physical_op._logical_operators: continue curr_physical_op.set_logical_operators(logical_op) # Add this operator to the op_map so optimizer can find it op_map[curr_physical_op] = logical_op queue.extend(curr_physical_op.input_dependencies) # Also add the final operator (in case the loop didn't catch it) op_map[physical_op] = logical_op return physical_op, op_map def _create_checkpoint_callback( self, checkpoint_config, ) -> LoadCheckpointCallback: """Factory method to create the LoadCheckpointCallback. Subclasses can override this to use a different callback implementation. """ return LoadCheckpointCallback( checkpoint_config, ) @staticmethod def _get_data_file_info(logical_plan: LogicalPlan): """Extract the data file directory and filesystem from the Write op's datasink. Returns (path, filesystem) for file-based datasinks, or (None, None) for non-file datasinks. """ last_op = logical_plan.dag if isinstance(last_op, Write): datasink = last_op.datasink_or_legacy_datasource if isinstance(datasink, _FileDatasink): return datasink.unresolved_path, datasink.filesystem return None, None def _get_plan_fns_for_checkpointing( self, data_file_dir: Optional[str] = None, data_file_filesystem: Optional["pyarrow.fs.FileSystem"] = None, ) -> Dict[Type[LogicalOperator], PlanLogicalOpFn]: plan_fns = { Read: partial( plan_read_op_with_checkpoint_filter, data_file_dir, data_file_filesystem ), ReadFiles: partial( plan_read_files_op_with_checkpoint_filter, data_file_dir, data_file_filesystem, ), Write: plan_write_op_with_checkpoint_writer, } return plan_fns def _check_supports_checkpointing(self, logical_plan: LogicalPlan) -> bool: """Check if the logical plan supports checkpointing. Subclasses can override _CHECKPOINT_FILTER_OPS to support more operators. """ if not isinstance(logical_plan.dag, (Write, StreamingSplit)): return False def _all_paths_contain_checkpoint_filter(op: LogicalOperator) -> bool: if isinstance(op, self._CHECKPOINT_FILTER_OPS): return True return all( _all_paths_contain_checkpoint_filter(input_dep) for input_dep in op.input_dependencies ) return _all_paths_contain_checkpoint_filter(logical_plan.dag) def find_plan_fn( logical_op: LogicalOperator, plan_fns: Dict[Type[LogicalOperator], PlanLogicalOpFn] ) -> Optional[PlanLogicalOpFn]: """Find the plan function for a logical operator. This function goes through the plan functions in order and returns the first one that is an instance of the logical operator type. Args: logical_op: The logical operator to find the plan function for. plan_fns: The dictionary of plan functions. Returns: The plan function for the logical operator, or None if no plan function is found. """ # TODO: This implementation doesn't account for type hierarchies conflicts or # multiple inheritance. for op_type, plan_fn in plan_fns.items(): if isinstance(logical_op, op_type): return plan_fn return None