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,32 @@
from .logical_operator import (
LogicalOperator,
LogicalOperatorPreservesSchema,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorSupportsPredicatePushdown,
LogicalOperatorSupportsProjectionPushdown,
LogicalOperatorUnifiesInputSchemas,
PredicatePassThroughBehavior,
)
from .logical_plan import LogicalPlan
from .operator import Operator
from .optimizer import Optimizer, Rule
from .physical_plan import PhysicalPlan
from .plan import Plan
from .source_operator import SourceOperator
__all__ = [
"LogicalOperator",
"LogicalPlan",
"Operator",
"Optimizer",
"PhysicalPlan",
"Plan",
"Rule",
"SourceOperator",
"LogicalOperatorPreservesSchema",
"LogicalOperatorSupportsProjectionPushdown",
"LogicalOperatorSupportsPredicatePushdown",
"LogicalOperatorSupportsPredicatePassThrough",
"LogicalOperatorUnifiesInputSchemas",
"PredicatePassThroughBehavior",
]
@@ -0,0 +1,247 @@
import copy
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, fields, replace
from enum import Enum
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional
from .operator import Operator
from ray.data.block import BlockMetadata
from ray.data.expressions import Expr
if TYPE_CHECKING:
from ray.data.block import Schema
@dataclass(frozen=True, repr=False, eq=False)
class LogicalOperator(Operator, ABC):
"""Abstract class for logical operators.
A logical operator describes transformation, and later is converted into
physical operator.
"""
_name: Optional[str] = field(init=False, default=None, repr=False)
_input_dependencies: List["LogicalOperator"] = field(
init=False, default_factory=list, repr=False
)
@property
def name(self) -> str:
return self._name or self.__class__.__name__
@property
def num_outputs(self) -> Optional[int]:
"""Expected number of output blocks, if known."""
return None
def estimated_num_outputs(self) -> Optional[int]:
"""Returns the estimated number of blocks that
would be outputted by this logical operator.
This method does not execute the plan, so it does not take into consideration
block splitting. This method only considers high-level block constraints like
`Dataset.repartition(num_blocks=X)`. A more accurate estimation can be given by
`PhysicalOperator.num_outputs_total()` during execution.
"""
if self.num_outputs is not None:
return self.num_outputs
elif len(self.input_dependencies) == 1:
return self.input_dependencies[0].estimated_num_outputs()
return None
# Override the following 3 methods to correct type hints.
@property
def input_dependencies(self) -> List["LogicalOperator"]:
value = self._input_dependencies
for x in value:
assert isinstance(x, LogicalOperator), x
return value
@input_dependencies.setter
def input_dependencies(self, value: List["LogicalOperator"]) -> None:
for x in value:
assert isinstance(x, LogicalOperator), x
object.__setattr__(self, "_input_dependencies", value)
def post_order_iter(self) -> Iterator["LogicalOperator"]:
return super().post_order_iter() # type: ignore
def _apply_transform(
self, transform: Callable[["LogicalOperator"], "LogicalOperator"]
) -> "LogicalOperator":
input_dependencies = self.input_dependencies
transformed_inputs = [
input_op._apply_transform(transform) for input_op in input_dependencies
]
if all(
transformed_input is input_op
for transformed_input, input_op in zip(
transformed_inputs, input_dependencies
)
):
target = self
else:
target = self._with_new_input_dependencies(transformed_inputs)
return transform(target)
def _with_new_input_dependencies(
self, input_dependencies: List["LogicalOperator"]
) -> "LogicalOperator":
if "input_dependencies" in {field.name for field in fields(self)}:
return replace(self, input_dependencies=input_dependencies)
target = copy.copy(self)
object.__setattr__(target, "_input_dependencies", input_dependencies)
return target
def _get_args(self) -> Dict[str, Any]:
"""This Dict must be serializable"""
args: Dict[str, Any] = {}
for dataclass_field in fields(self):
key = dataclass_field.name
value = getattr(self, key)
# Keep underscore-prefixed keys to preserve legacy export schema.
args[key if key.startswith("_") else f"_{key}"] = value
args["_name"] = self.name
# Preserve legacy export shape even though output deps are no longer tracked.
args["_output_dependencies"] = []
# Do not include input dependencies, since we only want to export this
# operator-specific args. Adding input_dependencies isn't wrong, but can
# lead to slow recursive calls with `sanitize_for_struct`, since logical
# operators are dataclasses.
args["_input_dependencies"] = []
return args
def infer_schema(self) -> Optional["Schema"]:
"""Returns the inferred schema of the output blocks."""
return None
def infer_metadata(self) -> "BlockMetadata":
"""A ``BlockMetadata`` that represents the aggregate metadata of the outputs.
This method is used by methods like :meth:`~ray.data.Dataset.schema` to
efficiently return metadata.
"""
return BlockMetadata(None, None, None, None)
def is_lineage_serializable(self) -> bool:
"""Returns whether the lineage of this operator can be serialized.
An operator is lineage serializable if you can serialize it on one machine and
deserialize it on another without losing information. Operators that store
object references (e.g., ``InputData``) aren't lineage serializable because the
objects aren't available on the deserialized machine.
"""
return True
class LogicalOperatorSupportsProjectionPushdown(LogicalOperator):
"""Mixin for reading operators supporting projection pushdown"""
def supports_projection_pushdown(self) -> bool:
return False
def get_projection_map(self) -> Optional[Dict[str, str]]:
return None
def apply_projection(
self,
projection_map: Optional[Dict[str, str]],
) -> LogicalOperator:
return self
class LogicalOperatorPreservesSchema(LogicalOperator):
"""Mixin for operators whose output column layout is identical to their
single input's. Provides a default ``infer_schema()`` that delegates to
the input. Use for ops like ``Filter``, ``Sort``, ``Limit``, etc., that
only re-order or filter rows.
List this mixin last in the bases of subclasses so the concrete operator
base (e.g., ``AbstractMap``, ``AbstractAllToAll``) drives ``__init__`` /
``super()`` chains.
"""
def infer_schema(self) -> Optional["Schema"]:
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
return self.input_dependencies[0].infer_schema()
class LogicalOperatorUnifiesInputSchemas(LogicalOperator):
"""Mixin for n-ary operators whose output schema is the unification of
all inputs' schemas (e.g., ``Union``, ``Mix``). Provides a default
``infer_schema()`` that returns the result of
``unify_schemas_with_validation`` over each input's schema, or
``None`` if any input's schema is unresolvable.
List this mixin last in the bases of subclasses so the concrete operator
base (e.g., ``NAry``) drives ``__init__`` / ``super()`` chains.
"""
def infer_schema(self) -> Optional["Schema"]:
import pyarrow as pa
from ray.data._internal.util import unify_schemas_with_validation
input_schemas = [op.infer_schema() for op in self.input_dependencies]
if not all(isinstance(s, pa.Schema) for s in input_schemas):
return None
try:
return unify_schemas_with_validation(input_schemas)
except (pa.ArrowTypeError, pa.ArrowInvalid):
return None
class LogicalOperatorSupportsPredicatePushdown(LogicalOperator):
"""Mixin for reading operators supporting predicate pushdown"""
def supports_predicate_pushdown(self) -> bool:
return False
def get_current_predicate(self) -> Optional[Expr]:
return None
def apply_predicate(
self,
predicate_expr: Expr,
) -> LogicalOperator:
return self
class PredicatePassThroughBehavior(Enum):
"""Defines how predicates can be passed through an operator."""
# Predicate can be pushed through as-is (e.g., Sort, Repartition, RandomShuffle, Limit)
PASSTHROUGH = "passthrough"
# Predicate can be pushed through but needs column rebinding (e.g., Project)
PASSTHROUGH_WITH_SUBSTITUTION = "passthrough_with_substitution"
# Predicate can be pushed into each branch (e.g., Union)
PUSH_INTO_BRANCHES = "push_into_branches"
# Predicate can be conditionally pushed based on columns (e.g., Join)
CONDITIONAL = "conditional"
class LogicalOperatorSupportsPredicatePassThrough(ABC):
"""Mixin for operators that allow predicates to be pushed through them.
This is distinct from LogicalOperatorSupportsPredicatePushdown, which is for
operators that can *accept* predicates (like Read). This trait is for operators
that allow predicates to *pass through* them.
"""
@abstractmethod
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
"""Returns the predicate passthrough behavior for this operator."""
pass
def get_column_substitutions(self) -> Optional[Dict[str, str]]:
"""Returns column renames needed when pushing through (for PASSTHROUGH_WITH_SUBSTITUTION).
Returns:
Dict mapping from old_name -> new_name, or None if no rebinding needed
"""
return None
@@ -0,0 +1,56 @@
from typing import TYPE_CHECKING, List, Optional
from .logical_operator import LogicalOperator
from .plan import Plan
if TYPE_CHECKING:
from ray.data.context import DataContext
class LogicalPlan(Plan):
"""The plan with a DAG of logical operators."""
def __init__(self, dag: LogicalOperator, context: "DataContext"):
super().__init__(context)
self._dag = dag
@property
def dag(self) -> LogicalOperator:
"""Get the DAG of logical operators."""
return self._dag
def sources(self) -> List[LogicalOperator]:
"""List of operators that are sources for this plan's DAG."""
# If an operator has no input dependencies, it's a source.
if not any(self._dag.input_dependencies):
return [self._dag]
sources = []
for op in self._dag.input_dependencies:
sources.extend(LogicalPlan(op, self.context).sources())
return sources
def has_lazy_input(self) -> bool:
"""Return whether this plan has lazy input blocks."""
from ray.data._internal.logical.operators import Read
return all(isinstance(op, Read) for op in self.sources())
def require_preserve_order(self) -> bool:
"""Whether this plan requires to preserve order."""
from ray.data._internal.logical.operators import Zip
return any(isinstance(op, Zip) for op in self.dag.post_order_iter())
def input_files(self) -> Optional[List[str]]:
"""Get the input files of the dataset, if available."""
input_files = self.dag.infer_metadata().input_files
if input_files is None:
return None
return list(set(input_files))
def initial_num_blocks(self) -> Optional[int]:
"""Get the estimated number of blocks from the logical plan
after applying execution plan optimizations, but prior to
fully executing the dataset."""
return self.dag.estimated_num_outputs()
@@ -0,0 +1,56 @@
from abc import ABC, abstractmethod
from typing import Callable, Iterator, List
class Operator(ABC):
"""Abstract class for operators.
Operators live on the driver side of the Dataset only.
"""
@property
@abstractmethod
def name(self) -> str:
"""Name for this operator."""
...
@property
def dag_str(self) -> str:
"""String representation of the whole DAG."""
if self.input_dependencies:
out_str = ", ".join([x.dag_str for x in self.input_dependencies])
out_str += " -> "
else:
out_str = ""
out_str += f"{self.__class__.__name__}[{self.name}]"
return out_str
@property
@abstractmethod
def input_dependencies(self) -> List["Operator"]:
"""List of operators that provide inputs for this operator."""
...
def post_order_iter(self) -> Iterator["Operator"]:
"""Depth-first traversal of this operator and its input dependencies."""
for op in self.input_dependencies:
yield from op.post_order_iter()
yield self
@abstractmethod
def _apply_transform(
self, transform: Callable[["Operator"], "Operator"]
) -> "Operator":
"""Recursively applies transformation (in post-order) to the operators DAG
NOTE: This operation should be opting in to avoid in-place modifications,
instead creating new operations whenever any operator needs to be
updated.
"""
...
def __repr__(self) -> str:
return f"{self.__class__.__name__}[{self.name}]"
def __str__(self) -> str:
return repr(self)
@@ -0,0 +1,52 @@
from typing import List, Type
from .plan import Plan
class Rule:
"""Abstract class for optimization rule."""
def apply(self, plan: Plan) -> Plan:
"""Apply the optimization rule to the execution plan."""
raise NotImplementedError
@classmethod
def dependencies(cls) -> List[Type["Rule"]]:
"""List of rules that must be applied before this rule."""
return []
@classmethod
def dependents(cls) -> List[Type["Rule"]]:
"""List of rules that must be applied after this rule."""
return []
class Optimizer:
"""Abstract class for optimizers.
An optimizers transforms a DAG of operators with a list of predefined rules.
"""
@property
def rules(self) -> List[Rule]:
"""List of predefined rules for this optimizer."""
raise NotImplementedError
def optimize(self, plan: Plan) -> Plan:
"""Optimize operators with a list of rules."""
# Apply rules until the plan is not changed
previous_plan = plan
while True:
for rule in self.rules:
plan = rule.apply(plan)
# TODO: Eventually we should implement proper equality.
# Using str to check equality seems brittle
if plan.dag.dag_str == previous_plan.dag.dag_str:
break
previous_plan = plan
return self._post_optimize(plan)
def _post_optimize(self, plan: Plan) -> Plan:
"""Post optimize is used for rules or other post-processing
that needs to be executed only once after the `optimize` loop."""
return plan
@@ -0,0 +1,34 @@
from typing import TYPE_CHECKING, Dict
from .logical_operator import LogicalOperator
from .plan import Plan
if TYPE_CHECKING:
from ray.data._internal.execution.interfaces import PhysicalOperator
from ray.data.context import DataContext
class PhysicalPlan(Plan):
"""The plan with a DAG of physical operators."""
def __init__(
self,
dag: "PhysicalOperator",
op_map: Dict["PhysicalOperator", LogicalOperator],
context: "DataContext",
):
super().__init__(context)
self._dag = dag
self._op_map = op_map
@property
def dag(self) -> "PhysicalOperator":
"""Get the DAG of physical operators."""
return self._dag
@property
def op_map(self) -> Dict["PhysicalOperator", LogicalOperator]:
"""
Get a mapping from physical operators to their corresponding logical operator.
"""
return self._op_map
@@ -0,0 +1,29 @@
from typing import TYPE_CHECKING
from .operator import Operator
if TYPE_CHECKING:
from ray.data.context import DataContext
class Plan:
"""Abstract class for logical/physical execution plans.
This plan should hold an operator representing the plan DAG and any auxiliary data
that's useful for plan optimization or execution.
"""
def __init__(self, context: "DataContext"):
self._context = context
@property
def dag(self) -> Operator:
raise NotImplementedError
@property
def context(self) -> "DataContext":
return self._context
@context.setter
def context(self, context: "DataContext") -> None:
self._context = context
@@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, List, Optional
if TYPE_CHECKING:
from ray.data.dataset import RefBundle
class SourceOperator(ABC):
"""Mixin for Logical operators that can be logical source nodes.
Subclasses: Read, InputData, FromAbstract.
"""
@abstractmethod
def output_data(self) -> Optional[List["RefBundle"]]:
"""The output data of this operator if already known, or ``None``."""
pass
@@ -0,0 +1,93 @@
"""Expose logical operator classes in ray.data._internal.logical.operators."""
from ray.data._internal.logical.operators.all_to_all_operator import (
AbstractAllToAll,
Aggregate,
RandomizeBlocks,
RandomShuffle,
Repartition,
Sort,
)
from ray.data._internal.logical.operators.count_operator import Count
from ray.data._internal.logical.operators.from_operators import (
AbstractFrom,
FromArrow,
FromBlocks,
FromItems,
FromNumpy,
FromPandas,
)
from ray.data._internal.logical.operators.input_data_operator import InputData
from ray.data._internal.logical.operators.join_operator import Join, JoinSide, JoinType
from ray.data._internal.logical.operators.map_operator import (
CSE_TEMP_COLUMN_PREFIX,
AbstractMap,
AbstractUDFMap,
Filter,
FlatMap,
MapBatches,
MapRows,
Project,
StreamingRepartition,
)
from ray.data._internal.logical.operators.n_ary_operator import (
Mix,
MixStoppingCondition,
NAry,
Union,
Zip,
)
from ray.data._internal.logical.operators.one_to_one_operator import (
AbstractOneToOne,
Download,
Limit,
)
from ray.data._internal.logical.operators.read_operator import (
ListFiles,
Read,
ReadFiles,
)
from ray.data._internal.logical.operators.streaming_split_operator import StreamingSplit
from ray.data._internal.logical.operators.write_operator import Write
__all__ = [
"AbstractAllToAll",
"AbstractFrom",
"AbstractMap",
"AbstractOneToOne",
"AbstractUDFMap",
"Aggregate",
"CSE_TEMP_COLUMN_PREFIX",
"Count",
"Download",
"Filter",
"FlatMap",
"FromArrow",
"FromBlocks",
"FromItems",
"FromNumpy",
"FromPandas",
"InputData",
"Join",
"JoinSide",
"JoinType",
"Limit",
"ListFiles",
"MapBatches",
"MapRows",
"Mix",
"MixStoppingCondition",
"NAry",
"Project",
"RandomShuffle",
"RandomizeBlocks",
"Read",
"ReadFiles",
"Repartition",
"Sort",
"StreamingRepartition",
"StreamingSplit",
"Union",
"Write",
"Zip",
]
@@ -0,0 +1,265 @@
from dataclasses import InitVar, dataclass, field, replace
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data._internal.planner.exchange.interfaces import ExchangeTaskSpec
from ray.data._internal.planner.exchange.shuffle_task_spec import ShuffleTaskSpec
from ray.data._internal.planner.exchange.sort_task_spec import SortKey, SortTaskSpec
from ray.data._internal.random_config import RandomSeedConfig
from ray.data.aggregate import AggregateFn
from ray.data.block import BlockMetadata
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"AbstractAllToAll",
"Aggregate",
"RandomShuffle",
"RandomizeBlocks",
"Repartition",
"Sort",
]
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractAllToAll(LogicalOperator):
"""Abstract class for logical operators should be converted to physical
AllToAllOperator.
"""
def __init__(
self,
input_dependencies: List[LogicalOperator],
sub_progress_bar_names: Optional[List[str]] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
*,
name: Optional[str] = None,
):
"""Initialize an ``AbstractAllToAll`` logical operator.
Args:
input_dependencies: The operators preceding this operator in the plan DAG.
The outputs of these operators will be the inputs to this operator.
sub_progress_bar_names: Optional sub-stage progress bar names for this
operator.
ray_remote_args: Args to provide to :func:`ray.remote`.
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
"""
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
if name is not None:
object.__setattr__(self, "_name", name)
object.__setattr__(self, "ray_remote_args", ray_remote_args or {})
object.__setattr__(self, "sub_progress_bar_names", sub_progress_bar_names)
@dataclass(frozen=True, repr=False, eq=False)
class RandomizeBlocks(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for randomize_block_order."""
seed_config: Optional[RandomSeedConfig] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.seed_config is None:
object.__setattr__(self, "seed_config", RandomSeedConfig())
object.__setattr__(self, "_name", "RandomizeBlockOrder")
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Randomizing block order doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class RandomShuffle(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for random_shuffle."""
name: InitVar[str] = "RandomShuffle"
seed_config: Optional[RandomSeedConfig] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self, name: str):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.seed_config is None:
object.__setattr__(self, "seed_config", RandomSeedConfig())
if self.sub_progress_bar_names is None:
object.__setattr__(
self,
"sub_progress_bar_names",
[
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
],
)
object.__setattr__(self, "_name", name)
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return replace(self, input_dependencies=input_dependencies, name=self._name)
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Random shuffle doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Repartition(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for repartition."""
shuffle: bool = False
keys: Optional[List[str]] = None
sort: bool = False
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
num_outputs: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.shuffle:
sub_progress_bar_names = [
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
]
else:
sub_progress_bar_names = [
ShuffleTaskSpec.SPLIT_REPARTITION_SUB_PROGRESS_BAR_NAME,
]
object.__setattr__(self, "sub_progress_bar_names", sub_progress_bar_names)
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return replace(
self,
input_dependencies=input_dependencies,
num_outputs=self.num_outputs,
)
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Repartition doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Sort(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for sort."""
sort_key: SortKey
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
object.__setattr__(
self,
"sub_progress_bar_names",
[
SortTaskSpec.SORT_SAMPLE_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
],
)
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Sort doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Aggregate(AbstractAllToAll):
"""Logical operator for aggregate."""
key: Optional[str | List[str]]
aggs: List[AggregateFn]
num_partitions: Optional[int] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
object.__setattr__(
self,
"sub_progress_bar_names",
[
SortTaskSpec.SORT_SAMPLE_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
],
)
def infer_schema(self) -> Optional["Schema"]:
# Output = key field(s) from input schema + one field per aggregator.
# Returns None if any aggregator can't declare its output field
# (callers fall back to limit(1)).
import pyarrow as pa
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
input_schema = self.input_dependencies[0].infer_schema()
if not isinstance(input_schema, pa.Schema):
return None
fields: List[pa.Field] = []
if self.key is not None:
keys = self.key if isinstance(self.key, list) else [self.key]
for key in keys:
try:
fields.append(input_schema.field(key))
except (KeyError, TypeError, ValueError):
return None
for agg in self.aggs:
f = agg.output_field(input_schema)
if f is None:
return None
fields.append(f)
return pa.schema(fields)
@@ -0,0 +1,35 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
from ray.data._internal.logical.interfaces import LogicalOperator
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"Count",
]
@dataclass(frozen=True, repr=False, eq=False)
class Count(LogicalOperator):
"""Logical operator that represents counting the number of rows in inputs.
Physical operators that implement this logical operator should produce one or more
rows with a single column named `Count.COLUMN_NAME`. When you sum the values in
this column, you should get the total number of rows in the dataset.
"""
COLUMN_NAME = "__num_rows"
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
def infer_schema(self) -> Optional["Schema"]:
# Fixed output: one row per partial count with a single ``__num_rows``
# int64 column.
import pyarrow as pa
return pa.schema([pa.field(self.COLUMN_NAME, pa.int64(), nullable=False)])
@@ -0,0 +1,133 @@
import abc
import functools
from dataclasses import InitVar, dataclass, field
from typing import TYPE_CHECKING, List, Optional, Union
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle
from ray.data._internal.logical.interfaces import LogicalOperator, SourceOperator
from ray.data._internal.util import unify_ref_bundles_schema
from ray.data.block import (
Block,
BlockMetadata,
BlockMetadataWithSchema,
)
from ray.types import ObjectRef
if TYPE_CHECKING:
import pyarrow as pa
ArrowTable = Union["pa.Table", bytes]
__all__ = [
"AbstractFrom",
"FromArrow",
"FromBlocks",
"FromItems",
"FromNumpy",
"FromPandas",
]
@dataclass(frozen=True, repr=False, eq=False)
class AbstractFrom(LogicalOperator, SourceOperator, metaclass=abc.ABCMeta):
"""Abstract logical operator for `from_*`."""
input_blocks: InitVar[List[ObjectRef[Block]]]
input_metadata: InitVar[List[BlockMetadataWithSchema]]
input_data: List[RefBundle] = field(init=False)
_input_dependencies: list[LogicalOperator] = field(
init=False, repr=False, default_factory=list
)
def __post_init__(
self,
input_blocks: List[ObjectRef[Block]],
input_metadata: List[BlockMetadataWithSchema],
):
assert len(input_blocks) == len(input_metadata), (
len(input_blocks),
len(input_metadata),
)
# `owns_blocks` is False because this op may be shared by multiple Datasets.
object.__setattr__(
self,
"input_data",
[
RefBundle(
[BlockEntry(input_blocks[i], input_metadata[i])],
owns_blocks=False,
schema=input_metadata[i].schema,
)
for i in range(len(input_blocks))
],
)
def output_data(self) -> Optional[List[RefBundle]]:
return self.input_data
@property
def num_outputs(self) -> Optional[int]:
return len(self.input_data)
@functools.cached_property
def _cached_output_metadata(self) -> BlockMetadata:
return BlockMetadata(
num_rows=self._num_rows(),
size_bytes=self._size_bytes(),
input_files=None,
exec_stats=None,
)
def _num_rows(self):
if all(bundle.num_rows() is not None for bundle in self.input_data):
return sum(bundle.num_rows() for bundle in self.input_data)
else:
return None
def _size_bytes(self):
metadata = [m for bundle in self.input_data for m in bundle.metadata]
if all(m.size_bytes is not None for m in metadata):
return sum(m.size_bytes for m in metadata)
else:
return None
def infer_metadata(self) -> BlockMetadata:
return self._cached_output_metadata
def infer_schema(self):
return unify_ref_bundles_schema(self.input_data)
def is_lineage_serializable(self) -> bool:
# This operator isn't serializable because it contains ObjectRefs.
return False
class FromItems(AbstractFrom):
"""Logical operator for `from_items`."""
pass
class FromBlocks(AbstractFrom):
"""Logical operator for `from_blocks`."""
pass
class FromNumpy(AbstractFrom):
"""Logical operator for `from_numpy`."""
pass
class FromArrow(AbstractFrom):
"""Logical operator for `from_arrow`."""
pass
class FromPandas(AbstractFrom):
"""Logical operator for `from_pandas`."""
pass
@@ -0,0 +1,64 @@
import functools
from dataclasses import dataclass, field
from typing import List, Optional
from ray.data._internal.execution.interfaces import RefBundle
from ray.data._internal.logical.interfaces import LogicalOperator, SourceOperator
from ray.data._internal.util import unify_schemas_with_validation
from ray.data.block import BlockMetadata
__all__ = [
"InputData",
]
@dataclass(frozen=True, repr=False, eq=False)
class InputData(LogicalOperator, SourceOperator):
"""Logical operator for input data.
This may hold cached blocks from a previous Dataset execution.
"""
input_data: List[RefBundle]
_input_dependencies: list[LogicalOperator] = field(
init=False, repr=False, default_factory=list
)
def output_data(self) -> Optional[List[RefBundle]]:
return self.input_data
@property
def num_outputs(self) -> Optional[int]:
return len(self.input_data)
def infer_metadata(self) -> BlockMetadata:
return self._cached_output_metadata
@functools.cached_property
def _cached_output_metadata(self) -> BlockMetadata:
return BlockMetadata(
num_rows=self._num_rows(),
size_bytes=self._size_bytes(),
input_files=None,
exec_stats=None,
)
def _num_rows(self):
if all(bundle.num_rows() is not None for bundle in self.input_data):
return sum(bundle.num_rows() for bundle in self.input_data)
else:
return None
def _size_bytes(self):
metadata = [m for bundle in self.input_data for m in bundle.metadata]
if all(m.size_bytes is not None for m in metadata):
return sum(m.size_bytes for m in metadata)
else:
return None
def infer_schema(self):
return unify_schemas_with_validation([data.schema for data in self.input_data])
def is_lineage_serializable(self) -> bool:
# This operator isn't serializable because it contains ObjectRefs.
return False
@@ -0,0 +1,263 @@
from dataclasses import InitVar, dataclass, field, replace
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data._internal.logical.operators.n_ary_operator import NAry
if TYPE_CHECKING:
from ray.data.dataset import Schema
from ray.data.expressions import Expr
__all__ = [
"Join",
"JoinSide",
"JoinType",
]
class JoinType(Enum):
INNER = "inner"
LEFT_OUTER = "left_outer"
RIGHT_OUTER = "right_outer"
FULL_OUTER = "full_outer"
LEFT_SEMI = "left_semi"
RIGHT_SEMI = "right_semi"
LEFT_ANTI = "left_anti"
RIGHT_ANTI = "right_anti"
class JoinSide(Enum):
"""Represents which side of a join to push a predicate to.
The enum values correspond to branch indices (0 for left, 1 for right).
"""
LEFT = 0
RIGHT = 1
@dataclass(frozen=True, repr=False, eq=False)
class Join(NAry, LogicalOperatorSupportsPredicatePassThrough):
"""Logical operator for join."""
left_input_op: InitVar[LogicalOperator]
right_input_op: InitVar[LogicalOperator]
join_type: Union[JoinType, str]
left_key_columns: Tuple[str]
right_key_columns: Tuple[str]
num_partitions: int
left_columns_suffix: Optional[str] = None
right_columns_suffix: Optional[str] = None
partition_size_hint: Optional[int] = None
aggregator_ray_remote_args: Optional[Dict[str, Any]] = None
_input_dependencies: list[LogicalOperator] = field(init=False, repr=False)
def __post_init__(
self,
left_input_op: LogicalOperator,
right_input_op: LogicalOperator,
):
try:
join_type_enum = JoinType(self.join_type)
except ValueError:
raise ValueError(
f"Invalid join type: '{self.join_type}'. "
f"Supported join types are: {', '.join(jt.value for jt in JoinType)}."
)
object.__setattr__(self, "join_type", join_type_enum)
object.__setattr__(
self,
"_input_dependencies",
[left_input_op, right_input_op],
)
# Mirror the reduce-task remote args onto `ray_remote_args` so fusing a downstream map
# into this join's reduce task respects the reduce task's actual args.
object.__setattr__(
self, "ray_remote_args", dict(self.aggregator_ray_remote_args or {})
)
@property
def num_outputs(self) -> Optional[int]:
return self.num_partitions
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return replace(
self,
left_input_op=input_dependencies[0],
right_input_op=input_dependencies[1],
num_partitions=self.num_outputs,
)
@staticmethod
def _validate_schemas(
left_op_schema: "Schema",
right_op_schema: "Schema",
left_key_column_names: Tuple[str],
right_key_column_names: Tuple[str],
):
def _col_names_as_str(keys: Sequence[str]):
keys_joined = ", ".join(map(lambda k: f"'{k}'", keys))
return f"[{keys_joined}]"
if len(left_key_column_names) < 1:
raise ValueError(
f"At least 1 column name to join on has to be provided (got "
f"{_col_names_as_str(left_key_column_names)})"
)
if len(left_key_column_names) != len(right_key_column_names):
raise ValueError(
f"Number of columns provided for left and right datasets has to match "
f"(got {_col_names_as_str(left_key_column_names)} and "
f"{_col_names_as_str(right_key_column_names)})"
)
def _get_key_column_types(schema: "Schema", keys: Tuple[str]):
return (
[
_type
for name, _type in zip(schema.names, schema.types)
if name in keys
]
if schema
else None
)
right_op_key_cols = _get_key_column_types(
right_op_schema, left_key_column_names
)
left_op_key_cols = _get_key_column_types(left_op_schema, right_key_column_names)
if left_op_key_cols != right_op_key_cols:
raise ValueError(
f"Key columns are expected to be present and have the same types "
"in both left and right operands of the join operation: "
f"left has {left_op_schema}, but right has {right_op_schema}"
)
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
return PredicatePassThroughBehavior.CONDITIONAL
def which_side_to_push_predicate(
self, predicate_expr: "Expr"
) -> Optional[JoinSide]:
"""Determine which side of the join to push a predicate to.
Returns the side to push to, or None if pushdown is not safe.
Predicate pushdown is safe for:
- INNER: Can push to either side
- LEFT_OUTER/SEMI/ANTI: Can push to left side (preserved/output side)
- RIGHT_OUTER/SEMI/ANTI: Can push to right side (preserved/output side)
- FULL_OUTER: Cannot push (both sides can generate nulls)
The predicate must reference columns from exactly one side of the join,
OR reference only join key columns that all exist on one side.
"""
# Get predicate columns and schemas
predicate_columns = self._get_referenced_columns(predicate_expr)
left_schema = self.input_dependencies[0].infer_schema()
right_schema = self.input_dependencies[1].infer_schema()
if not left_schema or not right_schema:
return None
# Get column sets for each side
left_columns = set(left_schema.names)
right_columns = set(right_schema.names)
left_join_keys = set(self.left_key_columns)
right_join_keys = set(self.right_key_columns)
# Get pushdown rules for this join type
can_push_left, can_push_right = self._get_pushdown_rules()
# Check if predicate can be evaluated on left side
# Condition: ALL predicate columns must exist on left (either as regular columns or join keys)
can_evaluate_on_left = predicate_columns.issubset(
left_columns
) or predicate_columns.issubset(left_join_keys)
if can_evaluate_on_left and can_push_left:
return JoinSide.LEFT
# Check if predicate can be evaluated on right side
can_evaluate_on_right = predicate_columns.issubset(
right_columns
) or predicate_columns.issubset(right_join_keys)
if can_evaluate_on_right and can_push_right:
return JoinSide.RIGHT
# Cannot push down
return None
def _get_pushdown_rules(self) -> Tuple[bool, bool]:
"""Get pushdown rules for the current join type.
Returns:
Tuple of (can_push_left, can_push_right) indicating which sides
can accept predicate pushdown for this join type.
"""
pushdown_rules = {
JoinType.INNER: (True, True),
JoinType.LEFT_OUTER: (True, False),
JoinType.RIGHT_OUTER: (False, True),
JoinType.LEFT_SEMI: (True, False),
JoinType.RIGHT_SEMI: (False, True),
JoinType.LEFT_ANTI: (True, False),
JoinType.RIGHT_ANTI: (False, True),
JoinType.FULL_OUTER: (False, False),
}
return pushdown_rules.get(self.join_type, (False, False))
def _get_referenced_columns(self, expr: "Expr") -> set[str]:
"""Extract all column names referenced in an expression."""
from ray.data._internal.planner.plan_expression.expression_visitors import (
_ColumnReferenceCollector,
)
visitor = _ColumnReferenceCollector()
visitor.visit(expr)
return set(visitor.get_column_refs())
def infer_schema(self) -> Optional["Schema"]:
"""Infer the output schema by running the shared ``join_tables``
utility on empty tables built from the input schemas. The same
utility runs at execution time, so plan-time and runtime schemas
agree by construction.
"""
import pyarrow as pa
from ray.data._internal.execution.operators.join import join_tables
left_schema = self.input_dependencies[0].infer_schema()
right_schema = self.input_dependencies[1].infer_schema()
if not isinstance(left_schema, pa.Schema) or not isinstance(
right_schema, pa.Schema
):
return None
join_type_enum = (
self.join_type
if isinstance(self.join_type, JoinType)
else JoinType(self.join_type)
)
try:
joined = join_tables(
left_schema.empty_table(),
right_schema.empty_table(),
join_type=join_type_enum,
left_key_col_names=tuple(self.left_key_columns),
right_key_col_names=tuple(self.right_key_columns),
left_columns_suffix=self.left_columns_suffix,
right_columns_suffix=self.right_columns_suffix,
)
except (pa.ArrowTypeError, pa.ArrowInvalid, pa.ArrowKeyError, ValueError):
return None
return joined.schema
@@ -0,0 +1,552 @@
import functools
import inspect
import logging
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
List,
Literal,
Optional,
Union,
)
from ray.data._internal.compute import ComputeStrategy, TaskPoolStrategy
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data._internal.logical.operators.one_to_one_operator import AbstractOneToOne
from ray.data.block import UserDefinedFunction
from ray.data.expressions import (
Expr,
StarExpr,
expand_star_exprs,
exprlist_to_fields,
)
from ray.data.preprocessor import Preprocessor
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"AbstractMap",
"AbstractUDFMap",
"Filter",
"FlatMap",
"MapBatches",
"MapRows",
"Project",
"StreamingRepartition",
"CSE_TEMP_COLUMN_PREFIX",
]
logger = logging.getLogger(__name__)
CSE_TEMP_COLUMN_PREFIX = "__ray_data_cse_"
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractMap(AbstractOneToOne):
"""Abstract class for logical operators that should be converted to physical
MapOperator.
"""
def __init__(
self,
name: Optional[str] = None,
input_dependencies: Optional[List[LogicalOperator]] = None,
*,
can_modify_num_rows: bool,
min_rows_per_bundled_input: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
compute: Optional[ComputeStrategy] = None,
):
"""Initialize an ``AbstractMap`` logical operator that will later
be converted into a physical ``MapOperator``.
Args:
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
input_dependencies: The operators preceding this operator in the plan
DAG. The outputs of these operators will be the inputs to this
operator.
can_modify_num_rows: Whether the operator can change the row count. False if
# of input rows = # of output rows. True otherwise.
min_rows_per_bundled_input: Minimum number of rows a single bundle of
blocks passed on to the task must possess.
ray_remote_args: Args to provide to :func:`ray.remote`.
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 it will
be called each time prior to initializing the worker. Args
returned from this dict always override the args in
``ray_remote_args``. Note: this is an advanced, experimental
feature.
compute: The compute strategy, either ``TaskPoolStrategy`` (default)
to use Ray tasks, or ``ActorPoolStrategy`` to use an
autoscaling actor pool.
"""
super().__init__(
input_dependencies=input_dependencies,
can_modify_num_rows=can_modify_num_rows,
name=name,
)
object.__setattr__(
self, "min_rows_per_bundled_input", min_rows_per_bundled_input
)
object.__setattr__(self, "ray_remote_args", ray_remote_args or {})
object.__setattr__(self, "ray_remote_args_fn", ray_remote_args_fn)
object.__setattr__(self, "compute", compute or TaskPoolStrategy())
object.__setattr__(self, "per_block_limit", None)
def set_per_block_limit(self, per_block_limit: int):
object.__setattr__(self, "per_block_limit", per_block_limit)
def _get_args(self) -> Dict[str, Any]:
args = super()._get_args()
for key in [
"can_modify_num_rows",
"min_rows_per_bundled_input",
"ray_remote_args",
"ray_remote_args_fn",
"compute",
"per_block_limit",
]:
args[f"_{key}"] = getattr(self, key)
return args
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractUDFMap(AbstractMap):
"""Abstract class for logical operators performing a UDF that should be converted
to physical MapOperator.
"""
fn: UserDefinedFunction
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
def __init__(
self,
name: str,
input_dependencies: List[LogicalOperator],
fn: UserDefinedFunction,
*,
can_modify_num_rows: bool,
fn_args: Optional[Iterable[Any]] = None,
fn_kwargs: Optional[Dict[str, Any]] = None,
fn_constructor_args: Optional[Iterable[Any]] = None,
fn_constructor_kwargs: Optional[Dict[str, Any]] = None,
min_rows_per_bundled_input: Optional[int] = None,
compute: Optional[ComputeStrategy] = None,
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
):
"""Initialize AbstractUDFMap.
Args:
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
input_dependencies: The operators preceding this operator in the plan DAG.
The outputs of these operators will be the inputs to this operator.
fn: User-defined function to be called.
can_modify_num_rows: Whether the UDF can change the row count. False if
# of input rows = # of output rows. True otherwise.
fn_args: Arguments to `fn`.
fn_kwargs: Keyword arguments to `fn`.
fn_constructor_args: Arguments to provide to the initializor of `fn` if
`fn` is a callable class.
fn_constructor_kwargs: Keyword Arguments to provide to the initializor of
`fn` if `fn` is a callable class.
min_rows_per_bundled_input: The target number of rows to pass to
``MapOperator._add_bundled_input()``.
compute: The compute strategy, either ``TaskPoolStrategy`` (default) to use
Ray tasks, or ``ActorPoolStrategy`` to use an autoscaling actor pool.
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: Args to provide to :func:`ray.remote`.
"""
name = self._get_operator_name(name, fn)
super().__init__(
name,
input_dependencies,
can_modify_num_rows=can_modify_num_rows,
min_rows_per_bundled_input=min_rows_per_bundled_input,
ray_remote_args=ray_remote_args,
compute=compute,
)
object.__setattr__(self, "fn", fn)
object.__setattr__(self, "fn_args", fn_args)
object.__setattr__(self, "fn_kwargs", fn_kwargs)
object.__setattr__(self, "fn_constructor_args", fn_constructor_args)
object.__setattr__(self, "fn_constructor_kwargs", fn_constructor_kwargs)
object.__setattr__(self, "ray_remote_args_fn", ray_remote_args_fn)
def _get_operator_name(self, op_name: str, fn: UserDefinedFunction):
"""Gets the Operator name including the map `fn` UDF name."""
# If the input `fn` is a Preprocessor, the
# name is simply the name of the Preprocessor class.
if inspect.ismethod(fn) and isinstance(fn.__self__, Preprocessor):
return fn.__self__.__class__.__name__
# Otherwise, it takes the form of `<MapOperator class>(<UDF name>)`,
# e.g. `MapBatches(my_udf)`.
try:
if inspect.isclass(fn):
# callable class
return f"{op_name}({fn.__name__})"
elif inspect.ismethod(fn):
# class method
return f"{op_name}({fn.__self__.__class__.__name__}.{fn.__name__})"
elif inspect.isfunction(fn):
# normal function or lambda function.
return f"{op_name}({fn.__name__})"
elif isinstance(fn, functools.partial):
# functools.partial
return f"{op_name}({fn.func.__name__})"
else:
# callable object.
return f"{op_name}({fn.__class__.__name__})"
except AttributeError as e:
logger.error("Failed to get name of UDF %s: %s", fn, e)
return "<unknown>"
@dataclass(frozen=True, repr=False, eq=False)
class MapBatches(AbstractUDFMap):
"""Logical operator for map_batches."""
fn: UserDefinedFunction
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = False
batch_size: Union[Optional[int], Literal["auto"]] = None
batch_format: Optional[str] = "default"
zero_copy_batch: bool = True
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
min_rows_per_bundled_input: Optional[int] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
self._get_operator_name(self.__class__.__name__, self.fn),
)
@dataclass(frozen=True, repr=False, eq=False)
class MapRows(AbstractUDFMap):
"""Logical operator for map."""
fn: UserDefinedFunction
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=False)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(self, "_name", self._get_operator_name("Map", self.fn))
@dataclass(frozen=True, repr=False, eq=False)
class Filter(AbstractUDFMap, LogicalOperatorPreservesSchema):
"""Logical operator for filter."""
predicate_expr: Optional[Expr] = None
fn: Optional[UserDefinedFunction] = None
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
provided_params = sum([self.fn is not None, self.predicate_expr is not None])
if provided_params != 1:
raise ValueError(
"Exactly one of 'fn', or 'predicate_expr' must be provided "
f"(received fn={self.fn}, predicate_expr={self.predicate_expr})"
)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
self._get_operator_name(self.__class__.__name__, self.fn),
)
def is_expression_based(self) -> bool:
return self.predicate_expr is not None
def _get_operator_name(self, op_name: str, fn: UserDefinedFunction):
if self.is_expression_based():
# Get a concise inline string representation of the expression
from ray.data._internal.planner.plan_expression.expression_visitors import (
_InlineExprReprVisitor,
)
expr_str = _InlineExprReprVisitor().visit(self.predicate_expr)
# Truncate only the final result if too long
max_length = 60
if len(expr_str) > max_length:
expr_str = expr_str[: max_length - 3] + "..."
return f"{op_name}({expr_str})"
return super()._get_operator_name(op_name, fn)
@dataclass(frozen=True, repr=False, eq=False)
class Project(AbstractMap, LogicalOperatorSupportsPredicatePassThrough):
"""Logical operator for all Projection Operations."""
exprs: list["Expr"]
_common_sub_exprs: list["Expr"] = field(
default_factory=list,
repr=False,
kw_only=True,
)
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
compute: Optional[ComputeStrategy] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
can_modify_num_rows: bool = field(init=False, default=False)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
batch_size: Optional[int] = field(init=False, default=None)
batch_format: str = field(init=False, default="pyarrow")
zero_copy_batch: bool = field(init=False, default=True)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
# Eagerly expand ``StarExpr`` when the input schema is known. By the time
# optimizer rules see this op, the projection list contains only
# explicit ``col()`` and computed expressions, no ``StarExpr``.
# When the input schema is opaque (e.g., upstream UDF map), the
# ``StarExpr`` is preserved and runtime ``eval_projection``
# expands it on a per-block basis.
import pyarrow as pa
input_schema = self.input_dependencies[0].infer_schema()
if isinstance(input_schema, pa.Schema):
object.__setattr__(
self, "exprs", expand_star_exprs(self.exprs, input_schema)
)
if self.compute is None:
object.__setattr__(
self,
"compute",
self._detect_and_get_compute_strategy(self.get_all_exprs()),
)
for expr in self.exprs:
if expr.name is None and not isinstance(expr, StarExpr):
raise TypeError(
"All Project expressions must be named (use .alias(name) or col(name)), "
"or be a star() expression."
)
def _detect_and_get_compute_strategy(self, exprs: list["Expr"]) -> ComputeStrategy:
"""Detect if expressions contain callable class UDFs and return appropriate compute strategy.
If any expression contains a callable class UDF, returns ActorPoolStrategy.
Otherwise returns TaskPoolStrategy.
"""
from ray.data._internal.planner.plan_expression.expression_visitors import (
_CallableClassUDFCollector,
)
# Check all expressions for callable class UDFs
for expr in exprs:
collector = _CallableClassUDFCollector()
collector.visit(expr)
if collector.get_callable_class_udfs():
# Found at least one callable class UDF - use actor semantics
from ray.data._internal.compute import ActorPoolStrategy
return ActorPoolStrategy(min_size=1, max_size=None)
# No callable class UDFs found - use task-based execution
from ray.data._internal.compute import TaskPoolStrategy
return TaskPoolStrategy()
def has_star_expr(self) -> bool:
return self.get_star_expr() is not None
def is_idempotent(self) -> bool:
"""Return whether every output expression of this projection is idempotent."""
return all(expr.is_idempotent() for expr in self.exprs)
def get_star_expr(self) -> Optional[StarExpr]:
"""Check if this projection contains a star() expression."""
for expr in self.exprs:
if isinstance(expr, StarExpr):
return expr
return None
def get_common_sub_exprs(self) -> list["Expr"]:
return self._common_sub_exprs
def get_all_exprs(self) -> list["Expr"]:
"""Both projection expressions and common expressions"""
return [*self._common_sub_exprs, *self.exprs]
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
return PredicatePassThroughBehavior.PASSTHROUGH_WITH_SUBSTITUTION
def get_column_substitutions(self) -> Optional[Dict[str, str]]:
"""Returns the column renames from this projection.
Maps source_column_name -> output_column_name. This is what we need
to rebind predicates when pushing through.
"""
# Reuse the existing logic from projection pushdown
from ray.data._internal.logical.rules.projection_pushdown import (
_extract_input_columns_renaming_mapping,
)
rename_map = _extract_input_columns_renaming_mapping(self.exprs)
return rename_map if rename_map else None
def infer_schema(self) -> Optional["Schema"]:
import pyarrow as pa
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
input_schema = self.input_dependencies[0].infer_schema()
# Only Arrow schemas are supported for static expression resolution.
# (``PandasBlockSchema`` chains fall back to ``limit(1)`` execution.)
if not isinstance(input_schema, pa.Schema):
return None
working_schema = input_schema
for common_expr in self.get_common_sub_exprs():
field = common_expr.to_field(working_schema)
if field is None:
return None
working_schema = working_schema.append(field)
fields = exprlist_to_fields(self.exprs, working_schema)
if fields is None:
return None
if self.get_common_sub_exprs():
temp_names = {expr.name for expr in self.get_common_sub_exprs()}
fields = [field for field in fields if field.name not in temp_names]
return pa.schema(fields)
@dataclass(frozen=True, repr=False, eq=False)
class FlatMap(AbstractUDFMap):
"""Logical operator for flat_map."""
fn: UserDefinedFunction
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
self._get_operator_name(self.__class__.__name__, self.fn),
)
@dataclass(frozen=True, repr=False, eq=False)
class StreamingRepartition(
AbstractMap,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for streaming repartition operation.
Args:
input_dependencies: The operators preceding this operator in the plan DAG.
target_num_rows_per_block: The target number of rows per block granularity for
streaming repartition.
strict: If True, guarantees that all output blocks, except for the last one,
will have exactly target_num_rows_per_block rows. If False, uses best-effort
bundling and may produce at most one block smaller than target_num_rows_per_block
per input block without forcing exact sizes through block splitting.
Defaults to False.
"""
target_num_rows_per_block: int
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
strict: bool = False
can_modify_num_rows: bool = field(init=False, default=False)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
compute: Optional[ComputeStrategy] = None
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.target_num_rows_per_block <= 0:
raise ValueError(
"target_num_rows_per_block must be positive for streaming repartition, "
f"got {self.target_num_rows_per_block}"
)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
f"StreamingRepartition[num_rows_per_block={self.target_num_rows_per_block},strict={self.strict}]",
)
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# StreamingRepartition only re-bundles rows into different block sizes.
# It doesn't modify schema or filter rows, so filters can safely pass through.
return PredicatePassThroughBehavior.PASSTHROUGH
@@ -0,0 +1,211 @@
import enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorUnifiesInputSchemas,
PredicatePassThroughBehavior,
)
from ray.util.annotations import PublicAPI
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"Mix",
"MixStoppingCondition",
"NAry",
"Union",
"Zip",
]
@PublicAPI(stability="alpha")
class MixStoppingCondition(enum.Enum):
"""Controls when a mix pipeline terminates.
STOP_ON_SHORTEST: Pipeline ends when the shortest dataset is exhausted.
Other datasets are truncated.
STOP_ON_LONGEST_DROP: Pipeline ends when the longest dataset is exhausted.
Shorter datasets drop out once exhausted; later batches are drawn
entirely from longer datasets.
"""
STOP_ON_SHORTEST = "stop_on_shortest"
STOP_ON_LONGEST_DROP = "stop_on_longest_drop"
def estimate_num_mix_outputs(
per_input_counts: List[Optional[int]],
weights: List[float],
stopping_condition: MixStoppingCondition,
) -> Optional[int]:
"""Estimate total output count for a mix operation.
Used by both the logical and physical Mix operators to estimate
num_outputs_total / num_output_rows_total.
"""
if any(c is None for c in per_input_counts):
return None
if stopping_condition == MixStoppingCondition.STOP_ON_LONGEST_DROP:
return sum(per_input_counts)
elif stopping_condition == MixStoppingCondition.STOP_ON_SHORTEST:
# Limited by whichever input runs out first relative to its weight.
total_weight = sum(weights)
return min(
int(count / (w / total_weight))
for count, w in zip(per_input_counts, weights)
)
else:
raise ValueError(f"Unknown stopping condition: {stopping_condition}")
@dataclass(frozen=True, repr=False, eq=False, init=False)
class NAry(LogicalOperator):
"""Base class for n-ary operators, which take multiple input operators."""
def __init__(
self,
input_dependencies: List[LogicalOperator],
):
"""Initialize the n-ary operator.
Args:
input_dependencies: The input operators.
"""
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return self.__class__(input_dependencies)
@dataclass(frozen=True, repr=False, eq=False, init=False)
class Zip(NAry):
"""Logical operator for zip."""
_input_dependencies: List[LogicalOperator] = field(init=False, repr=False)
def __init__(
self,
input_dependencies: List[LogicalOperator],
):
for input_op in input_dependencies:
assert isinstance(input_op, LogicalOperator), input_op
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
def estimated_num_outputs(self):
total_num_outputs = 0
for input in self.input_dependencies:
num_outputs = input.estimated_num_outputs()
if num_outputs is None:
return None
total_num_outputs = max(total_num_outputs, num_outputs)
return total_num_outputs
def infer_schema(self) -> Optional["Schema"]:
# Reuse the runtime ``BlockAccessor.zip`` so plan-time and
# execution-time schemas agree by construction (same column
# suffixing rules, etc.).
import pyarrow as pa
from ray.data.block import BlockAccessor
input_schemas = [op.infer_schema() for op in self.input_dependencies]
if not input_schemas or not all(
isinstance(s, pa.Schema) for s in input_schemas
):
return None
try:
combined = input_schemas[0].empty_table()
for s in input_schemas[1:]:
combined = BlockAccessor.for_block(combined).zip(s.empty_table())
except (pa.ArrowTypeError, pa.ArrowInvalid):
return None
return combined.schema
@dataclass(frozen=True, repr=False, eq=False, init=False)
class Mix(NAry, LogicalOperatorUnifiesInputSchemas):
"""Logical operator for weighted dataset mixing."""
_name: str = field(init=False, repr=False)
_input_dependencies: List[LogicalOperator] = field(init=False, repr=False)
weights: List[float] = field(init=False, repr=False)
stopping_condition: MixStoppingCondition = field(init=False, repr=False)
def __init__(
self,
input_dependencies: List[LogicalOperator],
*,
weights: List[float],
stopping_condition: MixStoppingCondition = MixStoppingCondition.STOP_ON_SHORTEST,
):
if len(input_dependencies) != len(weights):
raise ValueError(
f"Number of input operators ({len(input_dependencies)}) must match "
f"number of weights ({len(weights)})."
)
if any(weight <= 0 for weight in weights):
raise ValueError(f"Weights must be positive. Got weights: {weights}")
for input_op in input_dependencies:
assert isinstance(input_op, LogicalOperator), input_op
object.__setattr__(self, "_name", self.__class__.__name__)
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
object.__setattr__(self, "weights", weights)
object.__setattr__(self, "stopping_condition", stopping_condition)
def estimated_num_outputs(self) -> Optional[int]:
if self.stopping_condition == MixStoppingCondition.STOP_ON_SHORTEST:
return None
return estimate_num_mix_outputs(
[op.estimated_num_outputs() for op in self.input_dependencies],
self.weights,
self.stopping_condition,
)
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return self.__class__(
input_dependencies,
weights=self.weights,
stopping_condition=self.stopping_condition,
)
@dataclass(frozen=True, repr=False, eq=False, init=False)
class Union(
NAry,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorUnifiesInputSchemas,
):
"""Logical operator for union."""
_input_dependencies: List[LogicalOperator] = field(init=False, repr=False)
def __init__(
self,
input_dependencies: List[LogicalOperator],
):
for input_op in input_dependencies:
assert isinstance(input_op, LogicalOperator), input_op
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
def estimated_num_outputs(self):
total_num_outputs = 0
for input in self.input_dependencies:
num_outputs = input.estimated_num_outputs()
if num_outputs is None:
return None
total_num_outputs += num_outputs
return total_num_outputs
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Union allows pushing filter into each branch
return PredicatePassThroughBehavior.PUSH_INTO_BRANCHES
@@ -0,0 +1,185 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data.block import BlockMetadata
if TYPE_CHECKING:
import pyarrow
from ray.data.block import Schema
__all__ = [
"AbstractOneToOne",
"Download",
"Limit",
]
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractOneToOne(LogicalOperator):
"""Abstract class for one-to-one logical operators, which
have one input and one output dependency.
"""
def __init__(
self,
input_dependencies: Optional[List[LogicalOperator]],
can_modify_num_rows: bool,
*,
name: Optional[str] = None,
):
"""Initialize an AbstractOneToOne operator.
Args:
input_dependencies: The operators preceding this operator in the plan DAG.
The outputs of these operators will be the inputs to this operator.
can_modify_num_rows: Whether the UDF can change the row count. False if
# of input rows = # of output rows. True otherwise.
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
"""
object.__setattr__(self, "_input_dependencies", list(input_dependencies or []))
if name is not None:
object.__setattr__(self, "_name", name)
object.__setattr__(self, "can_modify_num_rows", can_modify_num_rows)
def infer_metadata(self) -> BlockMetadata:
"""Best-effort output metadata derived from the single input dependency.
One-to-one operators that don't modify the row count (e.g. ``Project``)
preserve the row count and don't grow the data, so the input's row count
and byte size are valid output estimates -- an upper bound for the common
column-selection case, where projecting away columns only shrinks the
data. Operators that can modify the row count (e.g. ``Filter``,
``FlatMap``) can't reuse these estimates, so they fall back to ``None``.
Propagating ``size_bytes`` is what keeps size-dependent planning correct
when a one-to-one op is pushed below a join/shuffle (e.g. by projection
pushdown): otherwise ``_try_estimate_output_bytes`` sees ``size_bytes=None``
and the hash-shuffle aggregator silently falls back to a fixed default
memory reservation instead of one derived from the dataset size.
"""
if len(self.input_dependencies) != 1:
return BlockMetadata(
num_rows=None, size_bytes=None, input_files=None, exec_stats=None
)
input_meta = self.input_dependencies[0].infer_metadata()
if self.can_modify_num_rows:
return BlockMetadata(
num_rows=None,
size_bytes=None,
input_files=input_meta.input_files,
exec_stats=None,
)
return BlockMetadata(
num_rows=input_meta.num_rows,
size_bytes=input_meta.size_bytes,
input_files=input_meta.input_files,
exec_stats=None,
)
@dataclass(frozen=True, repr=False, eq=False)
class Limit(
AbstractOneToOne,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for limit."""
limit: int
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = field(init=False, default=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
object.__setattr__(self, "_name", f"limit={self.limit}")
def infer_metadata(self) -> BlockMetadata:
return BlockMetadata(
num_rows=self._num_rows(),
size_bytes=None,
input_files=self._input_files(),
exec_stats=None,
)
def _num_rows(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
input_rows = self.input_dependencies[0].infer_metadata().num_rows
if input_rows is not None:
return min(input_rows, self.limit)
else:
return None
def _input_files(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata().input_files
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Pushing filter through limit is safe: Filter(Limit(data, n), pred)
# becomes Limit(Filter(data, pred), n), which filters earlier
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Download(AbstractOneToOne):
"""Logical operator for download operation.
Supports downloading from multiple URI columns in a single operation.
"""
uri_column_names: List[str]
output_bytes_column_names: List[str]
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
filesystem: Optional["pyarrow.fs.FileSystem"] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = field(init=False, default=False)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if len(self.uri_column_names) != len(self.output_bytes_column_names):
raise ValueError(
f"Number of URI columns ({len(self.uri_column_names)}) must match "
f"number of output columns ({len(self.output_bytes_column_names)})"
)
def infer_metadata(self) -> BlockMetadata:
# Download preserves the row count but appends a binary blob column per
# requested output, so the output is *larger* than the input -- often by
# orders of magnitude. Propagating the input's ``size_bytes`` (as the
# row-preserving default does) would be a misleading under-estimate that
# could starve downstream size-dependent planning (e.g. hash-shuffle
# aggregator memory reservation). Keep the accurate row count and input
# files, but report ``size_bytes=None`` since we can't estimate the
# downloaded bytes ahead of execution.
meta = super().infer_metadata()
return BlockMetadata(
num_rows=meta.num_rows,
size_bytes=None,
input_files=meta.input_files,
exec_stats=None,
)
def infer_schema(self) -> Optional["Schema"]:
# Output = input schema with one binary column appended per requested
# output name. The runtime (``download_bytes_threaded``) always appends
# via ``add_column`` without removing any pre-existing column of the
# same name, so name collisions produce duplicate columns here too.
import pyarrow as pa
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
input_schema = self.input_dependencies[0].infer_schema()
if not isinstance(input_schema, pa.Schema):
return None
fields = list(input_schema)
for name in self.output_bytes_column_names:
fields.append(pa.field(name, pa.binary(), nullable=True))
return pa.schema(fields)
@@ -0,0 +1,476 @@
import functools
import math
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union
from ray.data._internal.compute import ComputeStrategy
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsPredicatePushdown,
LogicalOperatorSupportsProjectionPushdown,
SourceOperator,
)
from ray.data._internal.logical.operators.map_operator import AbstractMap
from ray.data.block import (
Block,
BlockMetadata,
BlockMetadataWithSchema,
)
from ray.data.context import DataContext
from ray.data.datasource.datasource import Datasource, Reader
from ray.data.expressions import Expr
if TYPE_CHECKING:
import pyarrow as pa
from pyarrow.fs import FileSystem
from ray.data._internal.datasource_v2.listing.file_indexer import FileIndexer
from ray.data._internal.datasource_v2.partitioners.file_partitioner import (
FilePartitioner,
)
from ray.data._internal.datasource_v2.scanners.scanner import Scanner
from ray.data.datasource.file_based_datasource import FileShuffleConfig
from ray.data.datasource.partitioning import PathPartitionFilter
__all__ = [
"ListFiles",
"Read",
"ReadFiles",
]
@dataclass(frozen=True, repr=False, eq=False)
class Read(
AbstractMap,
SourceOperator,
LogicalOperatorSupportsProjectionPushdown,
LogicalOperatorSupportsPredicatePushdown,
):
"""Logical operator for read."""
datasource: Datasource
datasource_or_legacy_reader: Union[Datasource, Reader]
parallelism: int
num_outputs: Optional[int] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
compute: Optional[ComputeStrategy] = None
detected_parallelism: Optional[int] = None
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
ray_remote_args_fn: None = field(init=False, default=None)
per_block_limit: Optional[int] = None
_input_dependencies: list = field(init=False, repr=False, default_factory=list)
def __post_init__(self):
if self.compute is None:
from ray.data._internal.compute import TaskPoolStrategy
object.__setattr__(self, "compute", TaskPoolStrategy())
if self.ray_remote_args is None:
object.__setattr__(self, "ray_remote_args", {})
object.__setattr__(self, "_name", f"Read{self.datasource.get_name()}")
object.__setattr__(self, "_input_dependencies", [])
def output_data(self):
return None
def set_detected_parallelism(self, parallelism: int) -> "Read":
"""
Set the true parallelism that should be used during execution. This
should be specified by the user or detected by the optimizer.
"""
object.__setattr__(self, "detected_parallelism", parallelism)
return self
def get_detected_parallelism(self) -> int:
"""
Get the true parallelism that should be used during execution.
"""
return self.detected_parallelism
def estimated_num_outputs(self) -> Optional[int]:
return self.num_outputs or self._estimate_num_outputs()
def infer_metadata(self) -> BlockMetadata:
"""A ``BlockMetadata`` that represents the aggregate metadata of the outputs.
This method gets metadata from the read tasks. It doesn't trigger any actual
execution.
"""
return self._cached_output_metadata.metadata
def infer_schema(self):
return self._cached_output_metadata.schema
def _estimate_num_outputs(self) -> Optional[int]:
metadata = self._cached_output_metadata.metadata
# Handle edge-case of empty dataset
if metadata.size_bytes == 0:
return 0
target_max_block_size = DataContext.get_current().target_max_block_size
# In either case of
# - Total byte-size estimate not available
# - Target max-block-size not being configured
#
# We fallback to estimating number of outputs to be equivalent to the
# number of input files being read (if any)
if metadata.size_bytes is None or target_max_block_size is None:
# NOTE: If there's no input files specified, return the count (could be 0)
return (
len(metadata.input_files) if metadata.input_files is not None else None
)
# Otherwise, estimate total number of blocks from estimated total
# byte size
return math.ceil(metadata.size_bytes / target_max_block_size)
@functools.cached_property
def _cached_output_metadata(self) -> "BlockMetadataWithSchema":
# Legacy datasources might not implement `get_read_tasks`.
if self.datasource.should_create_reader:
empty_meta = BlockMetadata(None, None, None, None)
return BlockMetadataWithSchema.from_metadata(empty_meta, schema=None)
# HACK: Try to get a single read task to get the metadata.
read_tasks = self.datasource.get_read_tasks(1)
if len(read_tasks) == 0:
# If there are no read tasks, the dataset is probably empty.
empty_meta = BlockMetadata(
num_rows=0,
size_bytes=0,
input_files=None,
exec_stats=None,
)
return BlockMetadataWithSchema.from_metadata(empty_meta, schema=None)
# `get_read_tasks` isn't guaranteed to return exactly one read task.
metadata = [read_task.metadata for read_task in read_tasks]
if all(meta.num_rows is not None for meta in metadata):
num_rows = sum(meta.num_rows for meta in metadata)
original_num_rows = num_rows
# Apply per-block limit if set
if self.per_block_limit is not None:
num_rows = min(num_rows, self.per_block_limit)
else:
num_rows = None
original_num_rows = None
if all(meta.size_bytes is not None for meta in metadata):
size_bytes = sum(meta.size_bytes for meta in metadata)
# Pro-rate the byte size if we applied a row limit
if (
self.per_block_limit is not None
and original_num_rows is not None
and original_num_rows > 0
):
size_bytes = int(size_bytes * (num_rows / original_num_rows))
else:
size_bytes = None
input_files = []
for meta in metadata:
if meta.input_files is not None:
input_files.extend(meta.input_files)
meta = BlockMetadata(
num_rows=num_rows,
size_bytes=size_bytes,
input_files=input_files,
exec_stats=None,
)
schemas = [
read_task.schema for read_task in read_tasks if read_task.schema is not None
]
from ray.data._internal.util import unify_schemas_with_validation
schema = None
if schemas:
schema = unify_schemas_with_validation(schemas)
return BlockMetadataWithSchema.from_metadata(meta, schema=schema)
def supports_projection_pushdown(self) -> bool:
return self.datasource.supports_projection_pushdown()
def get_projection_map(self) -> Optional[Dict[str, str]]:
return self.datasource.get_projection_map()
def apply_projection(
self,
projection_map: Optional[Dict[str, str]],
) -> "Read":
projected_datasource = self.datasource.apply_projection(projection_map)
return replace(
self,
datasource=projected_datasource,
datasource_or_legacy_reader=projected_datasource,
num_outputs=self.num_outputs,
)
def supports_predicate_pushdown(self) -> bool:
return self.datasource.supports_predicate_pushdown()
def get_current_predicate(self) -> Optional[Expr]:
return self.datasource.get_current_predicate()
def apply_predicate(self, predicate_expr: Expr) -> "Read":
predicated_datasource = self.datasource.apply_predicate(predicate_expr)
# A datasource returns its own instance to signal "no pushdown applied"
# (e.g. ``ParquetDatasource`` does this when a mixed-column conjunct
# leaves a residual). Preserve identity here so ``PredicatePushdown``'s
# ``result_op is input_op`` no-op check keeps the ``Filter`` above.
if self.datasource is predicated_datasource:
return self
return replace(
self,
datasource=predicated_datasource,
datasource_or_legacy_reader=predicated_datasource,
num_outputs=self.num_outputs,
)
@dataclass(frozen=True, repr=False, eq=False)
class ReadFiles(
AbstractMap,
LogicalOperatorSupportsProjectionPushdown,
LogicalOperatorSupportsPredicatePushdown,
):
"""Logical operator for DataSourceV2 reads.
Consumes ``FileManifest`` blocks produced by a :class:`ListFiles`
source operator upstream. Owns the :class:`Scanner` (with any pushed
column/predicate/limit state) and the post-pushdown schema. Listing,
shuffling, and size-balanced bucketing happen in the upstream op;
this op's physical planner just reads each manifest bucket via
``scanner.create_reader().read(manifest)``.
V2 reads never rename columns at the read stage — column renaming
is always handled by a ``Project`` operator above ``ReadFiles``.
This simplifies projection and predicate pushdown by eliminating
the "predicate above uses new names, predicate below uses old
names" rebinding dance.
"""
datasource_name: str
scanner: "Scanner"
schema: "pa.Schema"
parallelism: int
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
compute: Optional[ComputeStrategy] = None
# Optional post-read block transform. Used by ``read_parquet``'s
# ``_block_udf`` and ``tensor_column_schema`` (the latter is folded
# into a ``_block_udf`` by ``_resolve_parquet_args`` before it gets
# here). Applied in ``plan_read_files_op.do_read`` after each
# table is read.
block_udf: Optional[Callable[[Block], Block]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
ray_remote_args_fn: None = field(init=False, default=None)
# Declared so the inherited ``AbstractMap._get_args`` can resolve it; V2
# limit pushdown is applied via ``scanner.push_limit`` (see
# ``LimitPushdownRule._apply_per_block_limit_if_supported``), not this field.
per_block_limit: Optional[int] = field(init=False, default=None)
num_outputs: Optional[int] = None
_name: str = field(init=False, repr=False)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(
self.input_dependencies[0], LogicalOperator
), self.input_dependencies[0]
if self.compute is None:
from ray.data._internal.compute import TaskPoolStrategy
object.__setattr__(self, "compute", TaskPoolStrategy())
if self.ray_remote_args is None:
object.__setattr__(self, "ray_remote_args", {})
object.__setattr__(self, "_name", f"ReadFiles{self.datasource_name}")
def infer_schema(self) -> "pa.Schema":
# Scanner schema reflects any applied projection pushdown
# (``scanner.prune_columns`` / empty projection from
# ``select_columns([])``); the stored ``self.schema`` is the
# unprojected one and only used for construction.
schema = self.scanner.read_schema()
# When a ``block_udf`` is attached (e.g. ``read_parquet`` was
# called with ``tensor_column_schema`` or ``_block_udf``), probe
# its effect on the schema so downstream consumers see the
# post-transform column types. Mirrors V1 ``ParquetDatasource``'s
# dummy-table trick. Falls back to the scanner schema if the
# probe fails — the UDF may require a non-empty input.
if self.block_udf is not None:
try:
transformed = self.block_udf(schema.empty_table()).schema
schema = transformed.with_metadata(schema.metadata)
except Exception:
pass
return schema
def infer_metadata(self) -> BlockMetadata:
"""Return empty metadata; downstream callers fall back to materialization.
Prior ``ReadFiles`` versions reached into a driver-side file cache to
compute size hints. With listing owned by an upstream
``ListFiles`` op, metadata-for-sizing is computed from the
materialized manifest at execution time — the logical op doesn't
try to pre-estimate.
"""
return BlockMetadata(None, None, None, None)
def supports_projection_pushdown(self) -> bool:
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsColumnPruning,
)
return isinstance(self.scanner, SupportsColumnPruning)
def get_projection_map(self) -> Optional[Dict[str, str]]:
if not self.supports_projection_pushdown():
return None
columns = self.scanner.pruned_column_names()
if columns is None:
return None
# The read stage never renames at the read layer; the projection
# map is always an identity (original name -> original name).
# Renaming is always carried by an ``AliasExpr`` in a ``Project``
# operator above the read.
return {name: name for name in columns}
def apply_projection(
self,
projection_map: Optional[Dict[str, str]],
) -> "ReadFiles":
if projection_map is None:
return self
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsColumnPruning,
)
assert isinstance(self.scanner, SupportsColumnPruning)
# V2 reads only prune columns at the read stage. Any rename info
# in ``projection_map`` is dropped here; the optimizer rule keeps
# a ``Project`` op on top of ``ReadFiles`` to carry rename
# ``AliasExpr`` instances. Only the keys (column names to keep)
# are used.
new_scanner = self.scanner.prune_columns(list(projection_map.keys()))
return replace(self, scanner=new_scanner)
def supports_predicate_pushdown(self) -> bool:
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsFilterPushdown,
)
return isinstance(self.scanner, SupportsFilterPushdown)
def get_current_predicate(self) -> Optional[Expr]:
return getattr(self.scanner, "predicate", None)
def apply_predicate(self, predicate_expr: Expr) -> LogicalOperator:
from ray.data._internal.datasource.parquet_datasource import (
_split_predicate_by_columns,
)
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsFilterPushdown,
SupportsPartitionPruning,
)
from ray.data._internal.logical.operators.map_operator import Filter
assert isinstance(self.scanner, SupportsFilterPushdown)
partition_cols: Set[str] = (
self.scanner.partition_columns
if isinstance(self.scanner, SupportsPartitionPruning)
else set()
)
if not partition_cols:
new_scanner, _residual = self.scanner.push_filters(predicate_expr)
return replace(self, scanner=new_scanner)
split = _split_predicate_by_columns(predicate_expr, partition_cols)
if split.data_predicate is None and split.partition_predicate is None:
# Entire predicate is residual (e.g. a single mixed-column
# ``OR``); nothing safe to push. Returning ``self`` tells
# ``PredicatePushdown`` to keep the ``Filter`` above us.
return self
new_scanner = self.scanner
if split.partition_predicate is not None:
new_scanner = new_scanner.prune_partitions(split.partition_predicate)
if split.data_predicate is not None:
new_scanner, _residual = new_scanner.push_filters(split.data_predicate)
new_op = replace(self, scanner=new_scanner)
if split.residual_predicate is None:
return new_op
# Residual conjuncts can't be pushed through either ``push_filters``
# (pyarrow only binds data columns) or ``prune_partitions`` (path
# parser only binds partition columns), so re-emit them as a
# ``Filter`` above the new ``ReadFiles``. Without this, we'd keep
# the splittable parts and silently drop the residual — letting
# rows through that the original predicate would have rejected.
return Filter(
predicate_expr=split.residual_predicate, input_dependencies=[new_op]
)
@dataclass(frozen=True, repr=False, eq=False)
class ListFiles(LogicalOperator, SourceOperator):
"""Logical source op that lists files and yields ``FileManifest`` blocks.
Extracted from the prior monolithic ``ReadFiles`` so listing, shuffling,
and size-balanced bucketing live in one place (see
:func:`ray.data._internal.planner.plan_list_files_op.plan_list_files_op`).
Downstream, ``ReadFiles`` consumes the manifest blocks produced here.
"""
paths: List[str]
file_indexer: "FileIndexer"
filesystem: "FileSystem"
# Original user-supplied paths. Lineage-tracking pins this to the
# caller's intent rather than the resolved absolute paths.
source_paths: List[str]
file_partitioner: Optional["FilePartitioner"] = None
file_extensions: Optional[List[str]] = None
partition_filter: Optional["PathPartitionFilter"] = None
# A factory (not a stored config) so the shuffle seed is re-sampled
# per execution when the config asks for it.
shuffle_config_factory: Callable[[], Optional["FileShuffleConfig"]] = field(
default=lambda: None
)
_name: str = field(init=False, repr=False)
_input_dependencies: List[LogicalOperator] = field(
init=False, repr=False, default_factory=list
)
def __post_init__(self):
object.__setattr__(self, "_name", self.__class__.__name__)
def output_data(self) -> Optional[list]:
return None
def infer_schema(self) -> "pa.Schema":
# ``FileManifest`` columns are fixed: __path, __file_size.
import pyarrow as pa
from ray.data._internal.datasource_v2.listing.file_manifest import (
FILE_SIZE_COLUMN_NAME,
PATH_COLUMN_NAME,
)
return pa.schema(
[
pa.field(PATH_COLUMN_NAME, pa.string()),
pa.field(FILE_SIZE_COLUMN_NAME, pa.int64()),
]
)
@@ -0,0 +1,27 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
)
if TYPE_CHECKING:
from ray.data._internal.execution.interfaces import NodeIdStr
__all__ = [
"StreamingSplit",
]
@dataclass(frozen=True, repr=False, eq=False)
class StreamingSplit(LogicalOperatorPreservesSchema):
"""Logical operator that represents splitting the input data to `n` splits."""
num_splits: int
equal: bool
locality_hints: Optional[List["NodeIdStr"]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
@@ -0,0 +1,216 @@
"""Unit tests for :class:`ReadFiles`.
Verifies pushdown scaffolding (projection/predicate capability dispatch,
immutable scanner substitution) and schema inference without triggering
physical execution. Each test wires a minimal ``ListFiles`` upstream
op so ``ReadFiles`` (which now has one input dependency) can be
constructed.
"""
import os
from pathlib import Path
from typing import Optional, Union
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
import pytest
from ray.data._internal.datasource_v2.listing.file_indexer import (
NonSamplingFileIndexer,
)
from ray.data._internal.datasource_v2.listing.listing_utils import (
sample_files,
)
from ray.data._internal.datasource_v2.parquet_datasource_v2 import (
ParquetDatasourceV2,
)
from ray.data._internal.datasource_v2.scanners.parquet_scanner import (
ParquetScanner,
)
from ray.data._internal.logical.operators import Filter, ListFiles, ReadFiles
from ray.data.datasource.partitioning import Partitioning, PartitionStyle
from ray.data.expressions import Expr, col
def _mk_parquet(path: Path, table: pa.Table) -> None:
pq.write_table(table, str(path))
def _mk_read_files(tmp_path: Path) -> ReadFiles:
f = tmp_path / "data.parquet"
_mk_parquet(f, pa.table({"a": [1, 2, 3], "b": ["x", "y", "z"]}))
datasource = ParquetDatasourceV2([str(f)])
indexer = NonSamplingFileIndexer(ignore_missing_paths=False)
sample = sample_files(indexer, datasource.paths, datasource.filesystem)
schema = datasource.infer_schema(sample)
scanner = datasource.create_scanner(schema=schema)
list_files_op = ListFiles(
paths=list(datasource.paths),
file_indexer=indexer,
filesystem=datasource.filesystem,
source_paths=list(datasource.paths),
file_extensions=datasource.file_extensions,
)
return ReadFiles(
datasource_name=datasource.name,
scanner=scanner,
schema=schema,
parallelism=-1,
input_dependencies=[list_files_op],
)
def _mk_partitioned_read_files(tmp_path: Path) -> ReadFiles:
"""Hive-partitioned dataset with partition column ``country``."""
for country, value in (("US", 1), ("CA", 2)):
d = tmp_path / f"country={country}"
os.makedirs(d, exist_ok=True)
_mk_parquet(d / "data.parquet", pa.table({"a": [value], "b": [str(value)]}))
partitioning = Partitioning(
PartitionStyle.HIVE, base_dir=str(tmp_path), field_names=["country"]
)
datasource = ParquetDatasourceV2([str(tmp_path)], partitioning=partitioning)
indexer = NonSamplingFileIndexer(ignore_missing_paths=False)
sample = sample_files(indexer, datasource.paths, datasource.filesystem)
schema = datasource.infer_schema(sample)
scanner = datasource.create_scanner(schema=schema, partitioning=partitioning)
list_files_op = ListFiles(
paths=list(datasource.paths),
file_indexer=indexer,
filesystem=datasource.filesystem,
source_paths=list(datasource.paths),
file_extensions=datasource.file_extensions,
)
return ReadFiles(
datasource_name=datasource.name,
scanner=scanner,
schema=schema,
parallelism=-1,
input_dependencies=[list_files_op],
)
def test_construction_stores_schema_and_infer_schema_returns_it(tmp_path):
op = _mk_read_files(tmp_path)
assert op.infer_schema().names == ["a", "b"]
def test_input_dependency_is_list_files(tmp_path):
op = _mk_read_files(tmp_path)
assert isinstance(op.input_dependencies[0], ListFiles)
def test_supports_projection_pushdown_true_for_parquet_scanner(tmp_path):
op = _mk_read_files(tmp_path)
assert op.supports_projection_pushdown() is True
def test_apply_projection_returns_new_op_with_pruned_scanner(tmp_path):
op = _mk_read_files(tmp_path)
new_op = op.apply_projection({"a": "a"})
assert new_op is not op
assert isinstance(new_op.scanner, ParquetScanner)
assert new_op.scanner.columns == ("a",)
# Original scanner untouched
assert isinstance(op.scanner, ParquetScanner)
assert op.scanner.columns is None
def test_apply_projection_none_is_noop(tmp_path):
op = _mk_read_files(tmp_path)
assert op.apply_projection(None) is op
def test_supports_predicate_pushdown(tmp_path):
assert _mk_read_files(tmp_path).supports_predicate_pushdown() is True
def _assert_pred_equals(
actual: Optional[Union[Expr, pc.Expression]], expected: Optional[Expr]
) -> None:
if expected is None:
assert actual is None
return
assert actual is not None
# ``ArrowFileScanner.push_filters`` stores the data predicate as a
# ``pc.Expression`` (via ``.to_pyarrow()``), while ``partition_predicate``
# stays a Ray ``Expr``. Dispatch on which we got.
if isinstance(actual, Expr):
assert actual.structurally_equals(expected)
else:
assert actual.equals(expected.to_pyarrow())
@pytest.mark.parametrize(
"partitioned,predicate,expected_data,expected_partition",
[
(False, col("a") > 1, col("a") > 1, None),
(True, col("country") == "US", None, col("country") == "US"),
(
True,
(col("a") > 0) & (col("country") == "US"),
col("a") > 0,
col("country") == "US",
),
],
ids=["data_only", "partition_only", "mixed_and"],
)
def test_apply_predicate_splits_data_and_partition(
tmp_path, partitioned, predicate, expected_data, expected_partition
):
op = (_mk_partitioned_read_files if partitioned else _mk_read_files)(tmp_path)
new_op = op.apply_predicate(predicate)
assert isinstance(new_op, ReadFiles) and new_op is not op
new_scanner = new_op.scanner
assert isinstance(new_scanner, ParquetScanner)
_assert_pred_equals(new_scanner.predicate, expected_data)
_assert_pred_equals(new_scanner.partition_predicate, expected_partition)
# Original scanner untouched.
orig_scanner = op.scanner
assert isinstance(orig_scanner, ParquetScanner)
assert orig_scanner.predicate is None
assert orig_scanner.partition_predicate is None
def test_apply_predicate_mixed_or_keeps_filter_above(tmp_path):
op = _mk_partitioned_read_files(tmp_path)
# Mixed-column ``OR`` can't be safely split — neither bucket is
# populated, so ``apply_predicate`` returns ``self`` and the rule
# leaves the ``Filter`` above ``ReadFiles`` untouched.
result = op.apply_predicate((col("a") > 0) | (col("country") == "US"))
assert result is op
def test_apply_predicate_mixed_and_with_unsplittable_residual(tmp_path):
op = _mk_partitioned_read_files(tmp_path)
# Top-level ``AND`` of one pure-data, one pure-partition, and one
# mixed-OR conjunct: the first two push, the OR stays as a residual
# ``Filter`` so we don't silently drop it.
pure_data = col("a") > 0
pure_partition = col("country") == "US"
mixed_or = (col("a") < 100) | (col("country") == "CA")
result = op.apply_predicate(pure_data & pure_partition & mixed_or)
assert isinstance(result, Filter)
new_read = result.input_dependencies[0]
assert isinstance(new_read, ReadFiles)
new_scanner = new_read.scanner
assert isinstance(new_scanner, ParquetScanner)
_assert_pred_equals(new_scanner.predicate, pure_data)
_assert_pred_equals(new_scanner.partition_predicate, pure_partition)
# The residual carried by the new Filter is exactly the mixed-OR
# conjunct that couldn't be pushed.
_assert_pred_equals(result.predicate_expr, mixed_or)
@@ -0,0 +1,46 @@
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Union
from ray.data._internal.compute import ComputeStrategy
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
)
from ray.data._internal.logical.operators.map_operator import AbstractMap
from ray.data.datasource.datasink import Datasink
from ray.data.datasource.datasource import Datasource
__all__ = [
"Write",
]
@dataclass(frozen=True, repr=False, eq=False)
class Write(AbstractMap, LogicalOperatorPreservesSchema):
"""Logical operator for write."""
datasink_or_legacy_datasource: Union[Datasink, Datasource]
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
compute: Optional[ComputeStrategy] = None
write_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False)
ray_remote_args_fn: None = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if isinstance(self.datasink_or_legacy_datasource, Datasink):
min_rows_per_bundled_input = (
self.datasink_or_legacy_datasource.min_rows_per_write
)
else:
min_rows_per_bundled_input = None
if self.compute is None:
from ray.data._internal.compute import TaskPoolStrategy
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self, "min_rows_per_bundled_input", min_rows_per_bundled_input
)
@@ -0,0 +1,99 @@
from typing import TYPE_CHECKING, List, Tuple
from ray.data._internal.planner import create_planner
if TYPE_CHECKING:
from ray.data._internal.execution.execution_callback import ExecutionCallback
from .ruleset import Ruleset
from ray.data._internal.logical.interfaces import (
LogicalPlan,
Optimizer,
PhysicalPlan,
Rule,
)
from ray.data._internal.logical.rules import (
CombineShuffles,
CommonSubExprElimination,
ConfigureMapTaskMemoryUsingOutputSize,
FuseOperators,
InheritTargetMaxBlockSizeRule,
LimitPushdownRule,
PredicatePushdown,
ProjectionPushdown,
SetReadParallelismRule,
)
from ray.util.annotations import DeveloperAPI
_LOGICAL_RULESET = Ruleset(
[
LimitPushdownRule,
ProjectionPushdown,
PredicatePushdown,
CombineShuffles,
]
)
_PHYSICAL_RULESET = Ruleset(
[
InheritTargetMaxBlockSizeRule,
SetReadParallelismRule,
FuseOperators,
ConfigureMapTaskMemoryUsingOutputSize,
]
)
@DeveloperAPI
def get_logical_ruleset() -> Ruleset:
return _LOGICAL_RULESET
@DeveloperAPI
def get_physical_ruleset() -> Ruleset:
return _PHYSICAL_RULESET
class LogicalOptimizer(Optimizer):
"""The optimizer for logical operators."""
@property
def rules(self) -> List[Rule]:
return [rule_cls() for rule_cls in get_logical_ruleset()]
def _post_optimize(self, plan: LogicalPlan) -> LogicalPlan:
# CommonSubExprElimination is only supposed to run once
# isolated from the optimizer rule loop as it applies to
# a single Projection operator not a chain of operators.
return CommonSubExprElimination().apply(plan)
class PhysicalOptimizer(Optimizer):
"""The optimizer for physical operators."""
@property
def rules(self) -> List[Rule]:
return [rule_cls() for rule_cls in get_physical_ruleset()]
def get_execution_plan(
logical_plan: LogicalPlan,
) -> Tuple[PhysicalPlan, List["ExecutionCallback"]]:
"""Get the physical execution plan for the provided logical plan.
This process has 3 steps:
(1) logical optimization: optimize logical operators.
(2) planning: convert logical to physical operators.
(3) physical optimization: optimize physical operators.
"""
# 1. Logical -> Logical (Optimized)
optimized_logical_plan = LogicalOptimizer().optimize(logical_plan)
# 2. Rewire Logical -> Logical (Optimized)
logical_plan._dag = optimized_logical_plan.dag
# 3. Logical (Optimized) -> Physical
physical_plan, callbacks = create_planner().plan(optimized_logical_plan)
# 4. Physical (Optimized) -> Physical
return PhysicalOptimizer().optimize(physical_plan), callbacks
@@ -0,0 +1,32 @@
"""Expose rule classes in ray.data._internal.logical.rules."""
from .combine_shuffles import CombineShuffles
from .common_subexpr_elimination import CommonSubExprElimination
from .configure_map_task_memory import (
ConfigureMapTaskMemoryRule,
ConfigureMapTaskMemoryUsingOutputSize,
)
from .inherit_target_max_block_size import InheritTargetMaxBlockSizeRule
from .limit_pushdown import LimitPushdownRule
from .operator_fusion import FuseOperators, are_remote_args_compatible
from .predicate_pushdown import PredicatePushdown
from .projection_pushdown import ProjectionPushdown
from .set_read_parallelism import (
SetReadParallelismRule,
compute_additional_split_factor,
)
__all__ = [
"CombineShuffles",
"CommonSubExprElimination",
"ConfigureMapTaskMemoryRule",
"ConfigureMapTaskMemoryUsingOutputSize",
"FuseOperators",
"InheritTargetMaxBlockSizeRule",
"LimitPushdownRule",
"PredicatePushdown",
"ProjectionPushdown",
"SetReadParallelismRule",
"are_remote_args_compatible",
"compute_additional_split_factor",
]
@@ -0,0 +1,86 @@
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalPlan,
Plan,
Rule,
)
from ray.data._internal.logical.operators import (
Aggregate,
Repartition,
Sort,
StreamingRepartition,
)
__all__ = [
"CombineShuffles",
]
class CombineShuffles(Rule):
"""This logical rule combines chained shuffles together. For example,
``Repartition`` and ``StreamingRepartition`` ops fusing them into a single one.
"""
def apply(self, plan: Plan) -> Plan:
assert isinstance(plan, LogicalPlan)
original_dag = plan.dag
transformed_dag = original_dag._apply_transform(self._combine)
if transformed_dag is original_dag:
return plan
# TODO replace w/ Plan.copy
return LogicalPlan(
dag=transformed_dag,
context=plan.context,
)
@classmethod
def _combine(self, op: LogicalOperator) -> LogicalOperator:
# Repartitions should have exactly 1 input
if len(op.input_dependencies) != 1:
return op
input_op = op.input_dependencies[0]
if isinstance(input_op, Repartition) and isinstance(op, Repartition):
shuffle = input_op.shuffle or op.shuffle
return Repartition(
num_outputs=op.num_outputs,
input_dependencies=[input_op.input_dependencies[0]],
shuffle=shuffle,
keys=op.keys,
sort=op.sort,
)
elif isinstance(input_op, StreamingRepartition) and isinstance(
op, StreamingRepartition
):
strict = input_op.strict or op.strict
return StreamingRepartition(
target_num_rows_per_block=op.target_num_rows_per_block,
input_dependencies=[input_op.input_dependencies[0]],
strict=strict,
)
elif isinstance(input_op, Repartition) and isinstance(op, Aggregate):
return Aggregate(
key=op.key,
aggs=op.aggs,
input_dependencies=[input_op.input_dependencies[0]],
num_partitions=op.num_partitions,
)
elif isinstance(input_op, StreamingRepartition) and isinstance(op, Repartition):
return Repartition(
num_outputs=op.num_outputs,
input_dependencies=[input_op.input_dependencies[0]],
shuffle=op.shuffle,
keys=op.keys,
sort=op.sort,
)
elif isinstance(input_op, Sort) and isinstance(op, Sort):
return Sort(
sort_key=op.sort_key,
input_dependencies=[input_op.input_dependencies[0]],
)
return op
@@ -0,0 +1,459 @@
import uuid
from collections import defaultdict
from dataclasses import dataclass, replace
from typing import Dict, Hashable, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalPlan,
Rule,
)
from ray.data._internal.logical.operators import CSE_TEMP_COLUMN_PREFIX, Project
from ray.data._internal.planner.plan_expression.expression_visitors import (
_ExpressionOccurrence,
_StructuralFingerprintOccurrenceCollector,
_StructuralFingerprintVisitor,
)
from ray.data.expressions import (
AliasExpr,
BinaryExpr,
ColumnExpr,
DownloadExpr,
Expr,
LiteralExpr,
MonotonicallyIncreasingIdExpr,
RandomExpr,
StarExpr,
UDFExpr,
UnaryExpr,
UUIDExpr,
)
__all__ = ["CommonSubExprElimination"]
@dataclass
class _Candidate:
"""A structurally duplicated expression that CSE may materialize once."""
expr: Expr
key: Hashable
occurrences: List[_ExpressionOccurrence]
temp_name: Optional[str] = None
@property
def max_depth(self) -> int:
"""Return the deepest AST depth at which this candidate appears."""
return max(o.depth for o in self.occurrences)
_IGNORED_CSE_ROOT_TYPES = (ColumnExpr, LiteralExpr, AliasExpr, StarExpr)
def _is_ignored_cse_root(expr: Expr) -> bool:
return isinstance(expr, _IGNORED_CSE_ROOT_TYPES)
def _collect_occurrences(exprs: List[Expr]) -> List[_ExpressionOccurrence]:
"""Collect structural occurrences from projection expressions.
Args:
exprs: Visible expression list from a ``Project`` operator.
Returns:
A flat list of occurrences, one per visited AST node, with each node's
structural key and depth computed by
``_StructuralFingerprintOccurrenceCollector``.
"""
collector = _StructuralFingerprintOccurrenceCollector()
for expr in exprs:
collector.visit(expr)
return collector.get_occurrences()
def _add_to_structural_group(
groups: List[List[_ExpressionOccurrence]], occurrence: _ExpressionOccurrence
) -> None:
"""Add one occurrence to the first structurally equivalent group.
Args:
groups: Existing structural groups for one fingerprint key. This list is
mutated in place.
occurrence: Occurrence with the same fingerprint key to place.
Returns:
``None``. The occurrence is appended to the first group whose
representative passes ``structurally_equals``; otherwise, a new
singleton group is appended.
"""
for group in groups:
if occurrence.expr.structurally_equals(group[0].expr):
group.append(occurrence)
return
groups.append([occurrence])
def _find_candidates(exprs: List[Expr]) -> List[_Candidate]:
"""Find duplicated, CSE-eligible sub-expressions in a projection.
Args:
exprs: Visible expression list from a ``Project`` operator.
Returns:
Candidates whose non-ignored root expressions occur more than once.
Ignored roots such as columns, literals, aliases, and stars are skipped
before structural grouping so wide projections do not pay unnecessary
exact-comparison cost for leaves that will never be materialized.
"""
occurrences = _collect_occurrences(exprs)
buckets: Dict[Hashable, List[List[_ExpressionOccurrence]]] = defaultdict(list)
for occurrence in occurrences:
# Skip ignored roots (columns, literals, aliases, stars) and non-idempotent
# expressions (random/uuid/monotonically_increasing_id). Materializing a
# non-idempotent sub-expression once would incorrectly collapse independent
# evaluations (e.g. two ``uuid()`` calls) into a single shared column.
if (
not _is_ignored_cse_root(occurrence.expr)
and occurrence.expr.is_idempotent()
):
_add_to_structural_group(buckets[occurrence.key], occurrence)
candidates: List[_Candidate] = []
for key, structural_groups in buckets.items():
for group in structural_groups:
if len(group) <= 1:
continue
candidates.append(
_Candidate(
expr=group[0].expr,
key=key,
occurrences=group,
)
)
return candidates
def _assign_temp_names(candidates: List[_Candidate]) -> None:
"""Assign hidden temporary column names to selected CSE candidates.
Args:
candidates: Candidate list returned by ``_find_candidates``. Each
candidate is mutated in place.
"""
project_token = uuid.uuid4().hex[:6]
for i, candidate in enumerate(candidates):
temp_name = f"{CSE_TEMP_COLUMN_PREFIX}{project_token}_{i}"
candidate.temp_name = temp_name
@dataclass(frozen=True)
class _Replacement:
expr: Expr
key: Hashable
temp_name: str
class _ReplacementIndex:
"""Lookup structure for exact replacement matches during expression rewrite."""
def __init__(self, replacements: List[_Replacement]):
"""Build a fingerprint bucket index from replacement records.
Args:
replacements: Zero or more replacements that are already safe to
reference.
"""
self._fingerprint = _StructuralFingerprintVisitor()
self._replacements_by_key: Dict[Hashable, List[_Replacement]] = defaultdict(
list
)
for replacement in replacements:
self._replacements_by_key[replacement.key].append(replacement)
def find(self, expr: Expr) -> Optional[_Replacement]:
"""Return the replacement for an exactly matching expression, if any.
Args:
expr: Expression node being considered for replacement.
Returns:
The matching replacement when both the structural fingerprint and
``structurally_equals`` match; otherwise, ``None``.
"""
key = self._fingerprint.visit(expr)
for replacement in self._replacements_by_key.get(key, []):
if expr.structurally_equals(replacement.expr):
return replacement
return None
def _candidate_to_replacement(candidate: _Candidate) -> _Replacement:
"""Convert a temp-named candidate into a rewriter replacement.
Args:
candidate: Candidate after temp-name assignment.
Returns:
An immutable replacement carrying the representative expression,
fingerprint key, and temp column name.
"""
assert candidate.temp_name is not None
return _Replacement(
expr=candidate.expr,
key=candidate.key,
temp_name=candidate.temp_name,
)
class _CSEExpressionRewriter:
"""Rewrite expression trees to read already-materialized CSE temp columns."""
def __init__(self, replacements: List[_Replacement]):
"""Create a rewriter over the replacements available in this phase.
Args:
replacements: Replacement list that may be referenced while
rewriting.
"""
self._replacement_index = _ReplacementIndex(replacements)
def rewrite_visible_expr(self, expr: Expr) -> Expr:
"""Rewrite a user-visible projection expression.
Args:
expr: One expression from ``Project.exprs`` after CSE candidates
have been selected.
Returns:
A semantically equivalent expression where the root itself may be
replaced by a temp column when the entire visible expression is
common.
"""
return self._rewrite(expr, allow_root_replacement=True)
def rewrite_materialization_expr(self, expr: Expr) -> Expr:
"""Rewrite the right-hand side for a hidden materialization expression.
Args:
expr: Representative expression for a candidate that will be emitted
as ``rhs.alias(temp_name)``.
Returns:
An expression that may replace child sub-expressions with previously
materialized temp columns, but never replaces the root candidate
itself.
"""
return self._rewrite(expr, allow_root_replacement=False)
def _maybe_replace(self, expr: Expr) -> Optional[ColumnExpr]:
"""Return a temp-column reference for a matching expression node.
Args:
expr: Expression node encountered by the recursive rewriter.
Returns:
``ColumnExpr(temp_name)`` when the node matches a replacement
exactly; otherwise, ``None`` so traversal can continue.
"""
replacement = self._replacement_index.find(expr)
if replacement is None:
return None
return ColumnExpr(replacement.temp_name)
def _rewrite(self, expr: Expr, *, allow_root_replacement: bool) -> Expr:
"""Recursively rewrite one expression node.
Args:
expr: Expression tree to rewrite.
allow_root_replacement: Whether the current root may become a temp
column reference.
Returns:
The original immutable leaf/synthetic expression, a ``ColumnExpr``
replacement, or a rebuilt expression whose children have been
rewritten.
Raises:
TypeError: If ``expr`` has an expression node type that CSE does not
know how to rewrite.
"""
if allow_root_replacement:
replacement = self._maybe_replace(expr)
if replacement is not None:
return replacement
if isinstance(expr, (ColumnExpr, LiteralExpr, StarExpr)):
return expr
if isinstance(expr, BinaryExpr):
return BinaryExpr(
expr.op,
self._rewrite(expr.left, allow_root_replacement=True),
self._rewrite(expr.right, allow_root_replacement=True),
)
if isinstance(expr, UnaryExpr):
return UnaryExpr(
expr.op,
self._rewrite(expr.operand, allow_root_replacement=True),
)
if isinstance(expr, AliasExpr):
visited = self._rewrite(
expr.expr,
allow_root_replacement=True,
)._unalias()
return replace(
expr,
expr=visited,
_is_rename=expr._is_rename and isinstance(visited, ColumnExpr),
)
if isinstance(expr, UDFExpr):
return replace(
expr,
args=[
self._rewrite(arg, allow_root_replacement=True) for arg in expr.args
],
kwargs={
key: self._rewrite(value, allow_root_replacement=True)
for key, value in expr.kwargs.items()
},
)
if isinstance(
expr,
(
DownloadExpr,
MonotonicallyIncreasingIdExpr,
RandomExpr,
UUIDExpr,
),
):
return expr
raise TypeError(f"Unsupported expression type for CSE rewrite: {type(expr)}")
def _build_cse_materialization_plan(candidates: List[_Candidate]) -> List[Expr]:
"""Build hidden expressions that compute common sub-expressions first.
Args:
candidates: Temp-named candidates selected for a ``Project``.
Returns:
Hidden alias expressions ordered deepest-first, so nested common
expressions are materialized before parent expressions that can
reference their temporary columns.
"""
ordered_candidates = sorted(
candidates,
key=lambda candidate: candidate.max_depth,
reverse=True,
)
materializations: List[Expr] = []
replacements: List[_Replacement] = []
for candidate in ordered_candidates:
assert candidate.temp_name is not None
rewriter = _CSEExpressionRewriter(replacements)
rhs = rewriter.rewrite_materialization_expr(candidate.expr)
materializations.append(rhs.alias(candidate.temp_name))
replacements.append(_candidate_to_replacement(candidate))
return materializations
def _rewrite_visible_exprs(
exprs: List[Expr],
candidates: List[_Candidate],
) -> List[Expr]:
"""Rewrite all user-visible projection expressions to use CSE temps.
Args:
exprs: Original visible ``Project.exprs``.
candidates: Temp-named candidates selected for that project.
Returns:
A new visible expression list where any matching common sub-expression,
including an entire root expression, reads from the corresponding hidden
temp column.
"""
replacements = [_candidate_to_replacement(candidate) for candidate in candidates]
rewriter = _CSEExpressionRewriter(replacements)
return [rewriter.rewrite_visible_expr(expr) for expr in exprs]
class CommonSubExprElimination(Rule):
"""Logical optimizer rule that materializes duplicated projection expressions.
This rule rewrites eligible ``Project`` operators so hidden common
sub-expression aliases are evaluated before the visible projection
expressions that read them.
"""
# Non-idempotent expressions (random/uuid/monotonically_increasing_id) are
# excluded from CSE candidacy via ``is_idempotent`` in ``_find_candidates``; the
# same contract guards ProjectionPushdown, PredicatePushdown, and LimitPushdown.
# TODO: Extend the contract to per-UDF non-determinism (see ``_IdempotencyVisitor``)
# and to other rules (e.g. constant folding) that can change expression
# evaluation count, timing, or placement.
def apply(self, plan: LogicalPlan) -> LogicalPlan:
"""Apply CSE to every eligible project in a logical plan.
Args:
plan: Logical plan that may contain repeated projection
sub-expressions.
Returns:
The original plan when no project changes, or a new logical plan
with transformed project nodes and the original context preserved.
"""
dag = plan.dag
new_dag = dag._apply_transform(self._try_optimize_project)
return LogicalPlan(new_dag, plan.context) if dag is not new_dag else plan
@classmethod
def _try_optimize_project(cls, op: LogicalOperator) -> LogicalOperator:
"""Rewrite one logical operator if it is an eligible ``Project``.
Args:
op: Logical operator visited during DAG traversal.
Returns:
The original operator for non-projects, already-CSE-optimized
projects, or projects with no candidates. Otherwise, returns a new
``Project`` containing hidden materialization expressions and
rewritten visible expressions while preserving the original project
execution settings.
"""
if not isinstance(op, Project):
return op
if op.get_common_sub_exprs():
return op
candidates = _find_candidates(op.exprs)
if not candidates:
return op
_assign_temp_names(candidates)
common_exprs = _build_cse_materialization_plan(candidates)
rewritten_exprs = _rewrite_visible_exprs(op.exprs, candidates)
return Project(
exprs=rewritten_exprs,
input_dependencies=op.input_dependencies,
compute=op.compute,
ray_remote_args=op.ray_remote_args,
ray_remote_args_fn=op.ray_remote_args_fn,
per_block_limit=op.per_block_limit,
_common_sub_exprs=common_exprs,
)
@@ -0,0 +1,86 @@
import abc
import copy
from typing import Any, Dict, Optional
from ray.data._internal.execution.operators.map_operator import MapOperator
from ray.data._internal.logical.interfaces import Rule
from ray.data._internal.logical.interfaces.physical_plan import PhysicalPlan
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
__all__ = [
"ConfigureMapTaskMemoryRule",
"ConfigureMapTaskMemoryUsingOutputSize",
]
class ConfigureMapTaskMemoryRule(Rule, abc.ABC):
def apply(self, plan: PhysicalPlan) -> PhysicalPlan:
for op in plan.dag.post_order_iter():
if not isinstance(op, MapOperator):
continue
def ray_remote_args_fn(
op: MapOperator = op, original_ray_remote_args_fn=op._ray_remote_args_fn
) -> Dict[str, Any]:
assert isinstance(op, MapOperator), op
static_ray_remote_args = copy.deepcopy(op._ray_remote_args)
dynamic_ray_remote_args = {}
if original_ray_remote_args_fn is not None:
dynamic_ray_remote_args = original_ray_remote_args_fn()
if (
"memory" not in static_ray_remote_args
and "memory" not in dynamic_ray_remote_args
# If this rule configures memory but the user hasn't specified
# memory in the placement group, then Ray won't be able to
# schedule tasks.
and not any(
isinstance(
scheduling_strategy, PlacementGroupSchedulingStrategy
)
for scheduling_strategy in (
static_ray_remote_args.get("scheduling_strategy"),
dynamic_ray_remote_args.get("scheduling_strategy"),
op.data_context.scheduling_strategy,
op.data_context.scheduling_strategy_large_args,
)
)
):
memory = self.estimate_per_task_memory_requirement(op)
if memory is not None:
dynamic_ray_remote_args["memory"] = memory
return dynamic_ray_remote_args
op._ray_remote_args_fn = ray_remote_args_fn
return plan
@abc.abstractmethod
def estimate_per_task_memory_requirement(self, op: MapOperator) -> Optional[int]:
"""Estimate the per-task memory requirement for the given map operator.
This is used to configure the `memory` argument in `ray.remote`.
"""
...
class ConfigureMapTaskMemoryUsingOutputSize(ConfigureMapTaskMemoryRule):
def estimate_per_task_memory_requirement(self, op: MapOperator) -> Optional[int]:
# Typically, this configuration won't make a difference because
# `average_bytes_per_output` is usually ~128 MiB and each core usually has
# 4 GiB of memory. However, if `num_cpus` is small (e.g., 0.01) or
# `target_max_block_size` is large (e.g., 1GB), then tasks can OOM even
# if it just uses enough memory to produce an output block. By setting
# `memory` to the average output size, we can mitigate this case.
#
# We set it to 1 target block size out of assumption that *at least* 1 copy
# of data (to process heap) will be made during processing.
#
# Note that, unless object store memory is manually specified, by default Ray's
# "memory" resource is exclusive of the Object Store memory allocated on the
# node (i.e., its total allocatable value is Total memory - Object Store
# memory).
return op.metrics.average_bytes_per_output
@@ -0,0 +1,34 @@
from typing import Optional
from ray.data._internal.execution.interfaces import PhysicalOperator
from ray.data._internal.logical.interfaces import PhysicalPlan, Rule
__all__ = [
"InheritTargetMaxBlockSizeRule",
]
class InheritTargetMaxBlockSizeRule(Rule):
"""For each op that has overridden the default target max block size,
propagate to upstream ops until we reach an op that has also overridden the
target max block size."""
def apply(self, plan: PhysicalPlan) -> PhysicalPlan:
self._propagate_target_max_block_size_to_upstream_ops(plan.dag)
return plan
def _propagate_target_max_block_size_to_upstream_ops(
self, dag: PhysicalOperator, target_max_block_size: Optional[int] = None
):
if dag.target_max_block_size_override is not None:
# Set the target block size to inherit for
# upstream ops.
target_max_block_size = dag.target_max_block_size_override
elif target_max_block_size is not None:
# Inherit from downstream op.
dag.override_target_max_block_size(target_max_block_size)
for upstream_op in dag.input_dependencies:
self._propagate_target_max_block_size_to_upstream_ops(
upstream_op, target_max_block_size
)
@@ -0,0 +1,275 @@
import copy
import logging
from dataclasses import is_dataclass, replace
from typing import List, Type
from ray.data._internal.logical.interfaces import LogicalOperator, LogicalPlan, Rule
from ray.data._internal.logical.operators import (
AbstractMap,
AbstractOneToOne,
Download,
Limit,
Project,
Read,
ReadFiles,
Union,
)
__all__ = [
"LimitPushdownRule",
]
logger = logging.getLogger(__name__)
class LimitPushdownRule(Rule):
"""Rule for pushing down the limit operator.
When a limit operator is present, we apply the limit on the
most upstream operator that supports it. We are conservative and only
push through operators that we know for certain do not modify row counts:
- Project operations (column selection)
- MapRows operations (row-wise transformations that preserve row count)
- Union operations (limits are prepended to each branch)
We stop at:
- Any operator that can modify the number of output rows (Sort, Shuffle, Aggregate, Read etc.)
For per-block limiting, we also set per-block limits on Read operators to optimize
I/O while keeping the Limit operator for exact row count control.
In addition, we also fuse consecutive Limit operators into a single
Limit operator, i.e. `Limit[n] -> Limit[m]` becomes `Limit[min(n, m)]`.
"""
@classmethod
def dependencies(cls) -> List[Type["Rule"]]:
# Run ProjectionPushdown and PredicatePushdown first. A `Project`
# (from `select_columns`, and from `read_parquet(columns=...)` which is
# rewired to it) or a `Filter` sits directly above the read. If limit
# pushdown runs first it slides the `Limit` in between that operator and
# the read, after which projection/predicate pushdown can no longer
# reach the read -- the column selection / filter is stranded above the
# `Limit` and the reader reads every column / every row. Applying those
# pushdowns first lets the selection and predicate be absorbed into the
# read while still adjacent, so the reader prunes columns and filters
# rows; the `Limit` then pushes down past the already-pruned read.
from ray.data._internal.logical.rules.predicate_pushdown import (
PredicatePushdown,
)
from ray.data._internal.logical.rules.projection_pushdown import (
ProjectionPushdown,
)
return [ProjectionPushdown, PredicatePushdown]
def apply(self, plan: LogicalPlan) -> LogicalPlan:
# The DAG's root is the most downstream operator.
def transform(node: LogicalOperator) -> LogicalOperator:
if isinstance(node, Limit):
# First, try to fuse with upstream Limit if possible (reuse fusion logic)
upstream_op = node.input_dependencies[0]
if isinstance(upstream_op, Limit):
# Fuse consecutive Limits: Limit[n] -> Limit[m] becomes Limit[min(n,m)]
new_limit = min(node.limit, upstream_op.limit)
return Limit(
new_limit,
input_dependencies=[upstream_op.input_dependencies[0]],
)
# If no fusion, apply pushdown logic
if isinstance(upstream_op, Union):
return self._push_limit_into_union(node)
else:
return self._push_limit_down(node)
return node
optimized_dag = plan.dag._apply_transform(transform)
return LogicalPlan(dag=optimized_dag, context=plan.context)
def _apply_limit_pushdown(self, op: LogicalOperator) -> LogicalOperator:
"""Push down Limit operators in the given operator DAG.
This implementation uses ``LogicalOperator._apply_transform`` to
post-order-traverse the DAG and rewrite each ``Limit`` node via
:py:meth:`_push_limit_down`.
"""
def transform(node: LogicalOperator) -> LogicalOperator:
if isinstance(node, Limit):
if isinstance(node.input_dependencies[0], Union):
return self._push_limit_into_union(node)
return self._push_limit_down(node)
return node
# ``_apply_transform`` returns the (potentially new) root of the DAG.
return op._apply_transform(transform)
def _push_limit_into_union(self, limit_op: Limit) -> Limit:
"""Push `limit_op` INTO every branch of its upstream Union
and preserve the global limit.
Existing topology:
child₁ , child₂ , … -> Union -> Limit
New topology:
child₁ -> Limit ->│
child₂ -> Limit ->┤ Union ──► Limit (original)
… -> Limit ->│
Example (skip duplicate limit on a branch that already has it):
before:
child -> Limit(n) -> Union -> Limit(n)
after:
child -> Limit(n) -> Union -> Limit(n) (no extra branch limit inserted)
"""
union_op = limit_op.input_dependencies[0]
assert isinstance(union_op, Union)
def _branch_has_limit(op: LogicalOperator, limit: int) -> bool:
current = op
while (
isinstance(current, AbstractOneToOne)
and not current.can_modify_num_rows
and current.input_dependencies
):
if isinstance(current, Limit):
return current.limit == limit
# Safe to use the first dependency: current is one-to-one here.
current = current.input_dependencies[0]
return isinstance(current, Limit) and current.limit == limit
# Insert a branch-local Limit and push it further upstream.
branch_tails: List[LogicalOperator] = []
for child in union_op.input_dependencies:
# Avoid inserting a duplicate Limit on a branch that already has the same
# limit upstream of row-preserving ops.
if _branch_has_limit(child, limit_op.limit):
branch_tails.append(child)
continue
raw_limit = Limit(limit_op.limit, input_dependencies=[child])
if isinstance(raw_limit.input_dependencies[0], Union):
# This represents the limit operator appended after the union.
pushed_tail = self._push_limit_into_union(raw_limit)
else:
# This represents the operator that takes place of the original limit position.
pushed_tail = self._push_limit_down(raw_limit)
branch_tails.append(pushed_tail)
new_union = Union(branch_tails)
return Limit(limit_op.limit, input_dependencies=[new_union])
def _push_limit_down(self, limit_op: Limit) -> LogicalOperator:
"""Push a single limit down through compatible operators conservatively.
Creates entirely new operators instead of mutating existing ones.
"""
# Traverse up the DAG until we reach the first operator that meets
# one of the stopping conditions
current_op = limit_op.input_dependencies[0]
num_rows_preserving_ops: List[LogicalOperator] = []
while (
isinstance(current_op, AbstractOneToOne)
and not current_op.can_modify_num_rows
):
if isinstance(current_op, Project) and not current_op.is_idempotent():
# Do not push the limit past a projection producing a non-idempotent
# column (e.g. monotonically_increasing_id): its value depends on row
# position / cardinality, which a reordered limit would change.
break
if isinstance(current_op, AbstractMap):
min_rows = current_op.min_rows_per_bundled_input
if min_rows is not None and min_rows > limit_op.limit:
# Avoid pushing the limit past batch-based maps that require more
# rows than the limit to produce stable outputs (e.g. schema).
logger.info(
f"Skipping push down of limit {limit_op.limit} through map {current_op} because it requires {min_rows} rows to produce stable outputs"
)
break
num_rows_preserving_ops.append(current_op)
current_op = current_op.input_dependencies[0]
# If we couldn't push through any operators, return original
if not num_rows_preserving_ops:
return limit_op
# Apply per-block limit to the deepest operator if it supports it
limit_input = self._apply_per_block_limit_if_supported(
current_op, limit_op.limit
)
# Build the new operator chain: Chain non-preserving number of rows -> Limit -> Operators preserving number of rows
new_limit = Limit(limit_op.limit, input_dependencies=[limit_input])
result_op = new_limit
# Recreate the intermediate operators and apply per-block limits
for op_to_recreate in reversed(num_rows_preserving_ops):
recreated_op = self._recreate_operator_with_new_input(
op_to_recreate, result_op
)
result_op = recreated_op
return result_op
def _apply_per_block_limit_if_supported(
self, op: LogicalOperator, limit: int
) -> LogicalOperator:
"""Apply per-block limit to operators that support it."""
if isinstance(op, AbstractMap):
if is_dataclass(op):
if isinstance(op, Read):
return replace(
op,
per_block_limit=limit,
num_outputs=op.num_outputs,
)
if isinstance(op, ReadFiles):
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsLimitPushdown,
)
if isinstance(op.scanner, SupportsLimitPushdown):
return replace(
op,
scanner=op.scanner.push_limit(limit),
)
return op
assert len(op.input_dependencies) == 1, len(op.input_dependencies)
return replace(
op,
input_dependencies=[op.input_dependencies[0]],
per_block_limit=limit,
)
new_op = copy.copy(op)
new_op.set_per_block_limit(limit)
return new_op
return op
def _recreate_operator_with_new_input(
self, original_op: LogicalOperator, new_input: LogicalOperator
) -> LogicalOperator:
"""Create a new operator of the same type as original_op but with new_input as its input."""
if isinstance(original_op, Limit):
return Limit(original_op.limit, input_dependencies=[new_input])
if isinstance(original_op, Download):
return Download(
uri_column_names=original_op.uri_column_names,
output_bytes_column_names=original_op.output_bytes_column_names,
input_dependencies=[new_input],
ray_remote_args=original_op.ray_remote_args,
filesystem=original_op.filesystem,
)
if isinstance(original_op, AbstractMap) and is_dataclass(original_op):
return replace(original_op, input_dependencies=[new_input])
# Use copy and replace input dependencies approach
new_op = copy.copy(original_op)
new_op.input_dependencies = [new_input]
return new_op
@@ -0,0 +1,890 @@
import itertools
import logging
from typing import Any, Dict, List, Optional, Union
from ray.data._internal.compute import (
ActorPoolStrategy,
ComputeStrategy,
TaskPoolStrategy,
)
from ray.data._internal.execution.bundle_queue import ExactMultipleSize, RebundleQueue
from ray.data._internal.execution.interfaces import (
PhysicalOperator,
RefBundle,
TaskContext,
)
from ray.data._internal.execution.interfaces.transform_fn import (
AllToAllTransformFnResult,
)
from ray.data._internal.execution.operators.actor_pool_map_operator import (
ActorPoolMapOperator,
)
from ray.data._internal.execution.operators.base_physical_operator import (
AllToAllOperator,
)
from ray.data._internal.execution.operators.limit_operator import LimitOperator
from ray.data._internal.execution.operators.map_operator import MapOperator
from ray.data._internal.execution.operators.shuffle_operators.shuffle_reduce_operator import (
ShuffleReduceOp,
)
from ray.data._internal.execution.operators.task_pool_map_operator import (
TaskPoolMapOperator,
)
from ray.data._internal.logical.interfaces import PhysicalPlan, Rule
from ray.data._internal.logical.operators import (
AbstractAllToAll,
AbstractMap,
AbstractUDFMap,
MapBatches,
RandomShuffle,
Repartition,
StreamingRepartition,
Write,
)
from ray.data.datasource.file_datasink import _FileDatasink
from ray.util.annotations import DeveloperAPI
__all__ = [
"FuseOperators",
"are_remote_args_compatible",
]
# Scheduling strategy and label selector can be inherited from upstream operator if not specified.
INHERITABLE_REMOTE_ARGS = ["scheduling_strategy", "label_selector"]
logger = logging.getLogger(__name__)
class FuseOperators(Rule):
"""Fuses linear chains of compatible physical operators."""
def apply(self, plan: PhysicalPlan) -> PhysicalPlan:
self._op_map = plan.op_map.copy()
# TODO(xgui): Currently we have to fuse streaming_repartition before map fusion
# because the result of map fusion loses the batch_size information.
# We should fix this by not losing the batch_size information when fusing map operators.
fused_dag = self._fuse_streaming_repartition_operators_in_dag(plan.dag)
# Do DFS fusion on compatible pairwise operators in two passes.
# In the first pass, only fuse back-to-back map operators together.
fused_dag = self._fuse_map_operators_in_dag(fused_dag)
# Now that we have fused together all back-to-back map operators,
# we fuse together MapOperator -> AllToAllOperator pairs.
fused_dag = self._fuse_all_to_all_operators_in_dag(fused_dag)
# Fuse a downstream task-pool map into the V2 hash-shuffle reduce phase.
# Runs after map fusion so a downstream map chain is already collapsed
# into one TaskPoolMapOperator.
fused_dag = self._fuse_map_into_shuffle_reduce_in_dag(fused_dag)
# Update output dependencies after fusion.
# TODO(hchen): Instead of updating the depdencies manually,
# we need a better abstraction for manipulating the DAG.
self._remove_output_deps(fused_dag)
self._update_output_deps(fused_dag)
new_plan = PhysicalPlan(fused_dag, self._op_map, plan.context)
return new_plan
def _remove_output_deps(self, op: PhysicalOperator) -> None:
for input in op.input_dependencies:
input._output_dependencies = []
self._remove_output_deps(input)
def _update_output_deps(self, op: PhysicalOperator) -> None:
for input in op.input_dependencies:
input._output_dependencies.append(op)
self._update_output_deps(input)
def _fuse_map_into_shuffle_reduce_in_dag(
self, dag: PhysicalOperator, has_downstream_limit: bool = False
) -> PhysicalOperator:
"""Starting at the given operator, traverses up the DAG and fuses a
task-pool map sitting directly downstream of a V2 hash-shuffle reduce
into the reduce (a ``ShuffleReduceOp -> TaskPoolMapOperator`` pair).
Returns the current (root) operator after completing upstream fusions.
"""
if self._can_fuse_map_into_shuffle_reduce(dag, has_downstream_limit):
dag = self._get_fused_map_into_shuffle_reduce_operator(
dag, dag.input_dependencies[0]
)
has_downstream_limit = has_downstream_limit or isinstance(dag, LimitOperator)
dag._input_dependencies = [
self._fuse_map_into_shuffle_reduce_in_dag(upstream_op, has_downstream_limit)
for upstream_op in dag.input_dependencies
]
return dag
def _can_fuse_map_into_shuffle_reduce(
self, dag: PhysicalOperator, has_downstream_limit: bool
) -> bool:
"""Whether ``dag`` is a task-pool map that can be fused into the V2
hash-shuffle reduce immediately upstream of it.
"""
# `dag` must be a fusable task-pool map.
if not (isinstance(dag, TaskPoolMapOperator) and dag.supports_fusion()):
return False
# Don't fuse a map with a `concurrency=` cap: the reduce runs one task
# per partition with no concurrency cap, so fusing would silently ignore
# the limit.
if dag.get_max_concurrency_limit() is not None:
return False
# Don't fuse under a downstream limit. A standalone map is throttled at
# task admission , but a fused reduce task runs the map over its whole
# partition before the limit can stop it this could materializing far
# more than requested.
if has_downstream_limit:
return False
# A non-file-datasink write defers `on_write_start` to the map op (e.g.
# Iceberg schema evolution), which the fused reduce never runs. File
# datasinks run it driver-side in `Dataset.write_datasink`, so they're
# safe; non-write maps have no such hook.
# TODO: support non-file-datasink writes by running the map's `on_start`
# hook in the fused reduce op.
logical_op = self._op_map.get(dag)
if isinstance(logical_op, Write) and not isinstance(
logical_op.datasink_or_legacy_datasource, _FileDatasink
):
return False
# The sole upstream must be a reduce that hasn't already fused with a map.
upstream_ops = dag.input_dependencies
if len(upstream_ops) != 1 or not isinstance(upstream_ops[0], ShuffleReduceOp):
return False
reduce_op = upstream_ops[0]
if reduce_op._fused_output_map_transformer is not None:
return False
return are_op_remote_args_compatible(self._op_map[reduce_op], self._op_map[dag])
def _fuse_streaming_repartition_operators_in_dag(
self, dag: PhysicalOperator
) -> PhysicalOperator:
"""Fuse (MapBatches -> StreamingRepartition) pair.
This will ensure the map_batch's function receive the correct number of rows.
We also ensure the output rows is `batch_size`.
Why don't we fuse `StreamingRepartition -> MapBatches`?
----------------------------------------------------------------------------------------------------
| | Number of `map_batches` tasks |
|----------------------|---------------------------------------------------------------------------|
| Fused | num_input_blocks (which is <= num output blocks of StreamingRepartition) |
| Not fused | num output blocks of StreamingRepartition |
----------------------------------------------------------------------------------------------------
When fused, the number of tasks equals the number of input blocks, which is
<= the number of output blocks of StreamingRepartition. If StreamingRepartition
is supposed to break down blocks to increase parallelism, that won't happen
when fused. So we don't fuse.
Why do we fuse `MapBatches -> StreamingRepartition` (when `batch_size % target_num_rows == 0`)?
----------------------------------------------------------
| | Number of `map_batches` tasks |
|----------------------|--------------------------------|
| Fused | total_rows / batch_size |
| Not fused | total_rows / batch_size |
----------------------------------------------------------
Parallelism is unchanged, so we fuse to avoid intermediate materialization.
"""
upstream_ops = dag.input_dependencies
while (
len(upstream_ops) == 1
and isinstance(self._op_map[dag], StreamingRepartition)
and isinstance(self._op_map[upstream_ops[0]], MapBatches)
and self._can_fuse(dag, upstream_ops[0])
):
dag = self._get_fused_streaming_repartition_operator(dag, upstream_ops[0])
upstream_ops = dag.input_dependencies
dag._input_dependencies = [
self._fuse_streaming_repartition_operators_in_dag(upstream_op)
for upstream_op in upstream_ops
]
return dag
def _fuse_map_operators_in_dag(self, dag: PhysicalOperator) -> MapOperator:
"""Starting at the given operator, traverses up the DAG of operators
and recursively fuses compatible MapOperator -> MapOperator pairs.
Returns the current (root) operator after completing upstream operator fusions.
"""
upstream_ops = dag.input_dependencies
while (
len(upstream_ops) == 1
and isinstance(dag, MapOperator)
and isinstance(upstream_ops[0], MapOperator)
and self._can_fuse(dag, upstream_ops[0])
):
# Fuse operator with its upstream op.
dag = self._get_fused_map_operator(dag, upstream_ops[0])
upstream_ops = dag.input_dependencies
# Done fusing back-to-back map operators together here,
# move up the DAG to find the next map operators to fuse.
dag._input_dependencies = [
self._fuse_map_operators_in_dag(upstream_op) for upstream_op in upstream_ops
]
return dag
def _fuse_all_to_all_operators_in_dag(
self, dag: AllToAllOperator
) -> AllToAllOperator:
"""Starting at the given operator, traverses up the DAG of operators
and recursively fuses compatible MapOperator -> AllToAllOperator pairs.
Also, sets the target block size of the immediately upstream map op to
match the shuffle block size. We use a larger block size for shuffles
because tiny blocks are bad for I/O performance.
Returns the current (root) operator after completing upstream operator fusions.
"""
upstream_ops = dag.input_dependencies
while (
len(upstream_ops) == 1
and isinstance(dag, AllToAllOperator)
and isinstance(upstream_ops[0], MapOperator)
and self._can_fuse(dag, upstream_ops[0])
):
# Fuse operator with its upstream op.
dag = self._get_fused_all_to_all_operator(dag, upstream_ops[0])
upstream_ops = dag.input_dependencies
# Done fusing MapOperator -> AllToAllOperator together here,
# move up the DAG to find the next pair of operators to fuse.
dag._input_dependencies = [
self._fuse_all_to_all_operators_in_dag(upstream_op)
for upstream_op in upstream_ops
]
return dag
def _can_fuse(self, down_op: PhysicalOperator, up_op: PhysicalOperator) -> bool:
"""Returns whether the provided downstream operator can be fused with the given
upstream operator.
We currently support fusing two operators if the following are all true:
* We are fusing either MapOperator -> MapOperator or
MapOperator -> AllToAllOperator.
* They either use the same compute configuration, or the upstream operator
uses a task pool while the downstream operator uses an actor pool.
* If both operators involve callable classes, the callable classes are
the same class AND constructor args are the same for both.
* They have compatible remote arguments.
"""
if not up_op.supports_fusion() or not down_op.supports_fusion():
return False
# We currently only support fusing for the following cases:
# - TaskPoolMapOperator -> TaskPoolMapOperator/ActorPoolMapOperator
# - TaskPoolMapOperator -> AllToAllOperator
# (only RandomShuffle and Repartition LogicalOperators are currently supported)
if not (
(
isinstance(up_op, TaskPoolMapOperator)
and isinstance(down_op, (TaskPoolMapOperator, ActorPoolMapOperator))
)
or (
isinstance(up_op, TaskPoolMapOperator)
and isinstance(down_op, AllToAllOperator)
)
):
return False
down_logical_op = self._op_map[down_op]
up_logical_op = self._op_map[up_op]
if up_op.get_additional_split_factor() > 1:
return False
# If the downstream operator takes no input, it cannot be fused with
# the upstream operator.
if not down_logical_op.input_dependencies:
return False
# We currently only support fusing for the following cases:
# - AbstractMap -> AbstractMap
# - AbstractMap -> RandomShuffle
# - AbstractMap -> Repartition (shuffle=True)
if not (
(
isinstance(up_logical_op, AbstractMap)
and isinstance(down_logical_op, AbstractMap)
and self._can_fuse_map_ops(up_logical_op, down_logical_op)
)
or (
isinstance(up_logical_op, AbstractMap)
and isinstance(down_logical_op, RandomShuffle)
)
# Do not fuse Repartition operator if shuffle is disabled
# (i.e. using split shuffle).
or (
isinstance(up_logical_op, AbstractMap)
and isinstance(down_logical_op, Repartition)
and down_logical_op.shuffle
)
):
return False
# Only fuse if the ops' remote arguments are compatible.
if not are_op_remote_args_compatible(up_logical_op, down_logical_op):
return False
if not self._can_merge_target_max_block_size(
up_op.target_max_block_size_override,
down_op.target_max_block_size_override,
):
return False
# only allow fusion of MapBatches -> StreamingRepartition
if isinstance(down_logical_op, StreamingRepartition):
if not (
isinstance(up_logical_op, MapBatches)
and down_logical_op.target_num_rows_per_block is not None
and down_logical_op.target_num_rows_per_block > 0
):
return False
# Non-strict mode: can always fuse, no matter what batch_size is.
# This allows fusion without cross-task buffering by using default bundler.
if not down_logical_op.strict:
return True
# Strict mode: only fuse when batch_size is a multiple of target_num_rows_per_block.
# When batch_size % target == 0, each batch can be perfectly sliced into chunks
# without cross-task buffering. See `_fuse_streaming_repartition_operators_in_dag`
# docstring for details.
# "auto" batch_size is resolved at task runtime, so divisibility is unknown at
# plan time — skip fusion and let the operators run separately.
return (
isinstance(up_logical_op.batch_size, int)
and up_logical_op.batch_size % down_logical_op.target_num_rows_per_block
== 0
)
# Other operators cannot fuse with StreamingRepartition.
if isinstance(up_logical_op, StreamingRepartition):
return False
# Otherwise, ops are compatible for fusion.
return True
def _get_fused_map_into_shuffle_reduce_operator(
self, down_op: TaskPoolMapOperator, up_op: ShuffleReduceOp
) -> ShuffleReduceOp:
name = up_op.name + "->" + down_op.name
up_logical_op = self._op_map.pop(up_op)
self._op_map.pop(down_op)
fused_op = ShuffleReduceOp(
up_op.input_dependencies,
up_op.data_context,
num_partitions=up_op._num_partitions,
reduce_fn=up_op._reduce_fn,
disallow_block_splitting=up_op._disallow_block_splitting,
reduce_ray_remote_args=up_op._reduce_ray_remote_args,
name=name,
fused_output_map_transformer=down_op.get_map_transformer(),
fused_output_map_task_kwargs=down_op.get_map_task_kwargs(),
fused_output_map_target_max_block_size_override=(
down_op.target_max_block_size_override
),
)
fused_op.set_logical_operators(
*up_op._logical_operators, *down_op._logical_operators
)
self._op_map[fused_op] = up_logical_op
return fused_op
def _get_fused_streaming_repartition_operator(
self, down_op: PhysicalOperator, up_op: PhysicalOperator
) -> PhysicalOperator:
assert self._can_fuse(down_op, up_op), (
"Current rule supports fusing MapBatches->StreamingRepartition, but received: "
f"{type(up_op).__name__} -> {type(down_op).__name__}"
)
name = up_op.name + "->" + down_op.name
down_logical_op = self._op_map.pop(down_op)
up_logical_op = self._op_map.pop(up_op)
assert isinstance(up_logical_op, MapBatches)
assert isinstance(down_logical_op, StreamingRepartition)
batch_size = up_logical_op.batch_size
# Choose ref_bundler and fusion behavior based on strict mode
if down_logical_op.strict:
# Strict mode: use StreamingRepartitionRefBundler for stitching.
# Only works when batch_size % target == 0 (verified in _can_fuse).
assert batch_size % down_logical_op.target_num_rows_per_block == 0, (
f"Strict mode fusion requires batch_size ({batch_size}) to be "
f"a multiple of target_num_rows_per_block "
f"({down_logical_op.target_num_rows_per_block})"
)
ref_bundler = RebundleQueue(ExactMultipleSize(batch_size))
else:
# Non-strict mode: use default pass-through bundler.
# Works with any batch_size without cross-task buffering.
ref_bundler = None
compute = self._fuse_compute_strategy(
up_logical_op.compute, down_logical_op.compute
)
assert compute is not None
map_task_kwargs = {**up_op._map_task_kwargs, **down_op._map_task_kwargs}
ray_remote_args = up_logical_op.ray_remote_args
ray_remote_args_fn = (
up_logical_op.ray_remote_args_fn or down_logical_op.ray_remote_args_fn
)
input_deps = up_op.input_dependencies
assert len(input_deps) == 1
input_op = input_deps[0]
assert up_op.data_context is down_op.data_context
# In non-strict mode, use min_rows_per_bundle to ensure creating batches with batch_size.
# In strict mode, ref_bundler handles bundling, so do not set min_rows_per_bundle.
# "auto" batch_size is resolved at task runtime, so we cannot set a fixed
# min_rows_per_bundle at plan time — leave it as None and let bundling use its default.
min_rows = (
None if (down_logical_op.strict or batch_size == "auto") else batch_size
)
op = MapOperator.create(
up_op.get_map_transformer().fuse(down_op.get_map_transformer()),
input_op,
up_op.data_context,
name=name,
compute_strategy=compute,
ref_bundler=ref_bundler,
min_rows_per_bundle=min_rows,
map_task_kwargs=map_task_kwargs,
ray_remote_args=ray_remote_args,
ray_remote_args_fn=ray_remote_args_fn,
supports_fusion=True,
)
op.set_logical_operators(*up_op._logical_operators, *down_op._logical_operators)
for map_task_kwargs_fn in itertools.chain(
up_op._map_task_kwargs_fns, down_op._map_task_kwargs_fns
):
op.add_map_task_kwargs_fn(map_task_kwargs_fn)
input_op = up_logical_op.input_dependencies[0]
logical_op = AbstractUDFMap(
name,
[input_op],
up_logical_op.fn,
can_modify_num_rows=up_logical_op.can_modify_num_rows,
fn_args=up_logical_op.fn_args,
fn_kwargs=up_logical_op.fn_kwargs,
fn_constructor_args=up_logical_op.fn_constructor_args,
fn_constructor_kwargs=up_logical_op.fn_constructor_kwargs,
min_rows_per_bundled_input=min_rows,
compute=compute,
ray_remote_args_fn=ray_remote_args_fn,
ray_remote_args=ray_remote_args,
)
self._op_map[op] = logical_op
return op
@classmethod
def _fuse_compute_strategy(
cls, up_compute: ComputeStrategy, down_compute: ComputeStrategy
) -> Optional[ComputeStrategy]:
"""Fuse the compute strategies of the upstream and downstream operators.
Returns None if they are not compatible.
Task->Task and Task->Actor are allowed.
Actor->Actor and Actor->Task are not allowed.
"""
if isinstance(up_compute, ActorPoolStrategy):
return None
assert isinstance(up_compute, TaskPoolStrategy)
if isinstance(down_compute, TaskPoolStrategy):
# For Task->Task, the sizes must match.
if up_compute.size != down_compute.size:
return None
return down_compute
else:
assert isinstance(down_compute, ActorPoolStrategy)
# For Task->Actor, if Task's size is set, it must match Actor's max_size.
if up_compute.size is not None and up_compute.size != down_compute.max_size:
return None
return down_compute
def _can_merge_target_max_block_size(
self,
up_target_max_block_size: Optional[int],
down_target_max_block_size: Optional[int],
) -> bool:
if (
up_target_max_block_size is not None
and down_target_max_block_size is not None
):
# NOTE: In case of both ops overriding `target_max_block_size` only
# merge them if settings are equal
return down_target_max_block_size == up_target_max_block_size
return True
def _get_merged_target_max_block_size(
self,
up_target_max_block_size: Optional[int],
down_target_max_block_size: Optional[int],
) -> Optional[int]:
assert self._can_merge_target_max_block_size(
up_target_max_block_size, down_target_max_block_size
)
return up_target_max_block_size or down_target_max_block_size
def _get_fused_map_operator(
self, down_op: MapOperator, up_op: MapOperator
) -> MapOperator:
assert self._can_fuse(down_op, up_op), (
"Current rule supports fusing MapOperator->MapOperator, but received: "
f"{type(up_op).__name__} -> {type(down_op).__name__}"
)
# Fuse operator names.
name = up_op.name + "->" + down_op.name
down_logical_op = self._op_map.pop(down_op)
up_logical_op = self._op_map.pop(up_op)
assert isinstance(down_logical_op, AbstractMap)
assert isinstance(up_logical_op, AbstractMap)
# Derive min num rows per input bundle
min_rows_per_bundled_input = self._derive_bundle_min_num_rows(
down_logical_op, up_logical_op
)
target_max_block_size = self._get_merged_target_max_block_size(
up_op.target_max_block_size_override, down_op.target_max_block_size_override
)
compute = self._fuse_compute_strategy(
up_logical_op.compute, down_logical_op.compute
)
assert compute is not None
# Merge map task kwargs
map_task_kwargs = {**up_op._map_task_kwargs, **down_op._map_task_kwargs}
ray_remote_args = up_logical_op.ray_remote_args
ray_remote_args_fn = (
up_logical_op.ray_remote_args_fn or down_logical_op.ray_remote_args_fn
)
# Make the upstream operator's inputs the new, fused operator's inputs.
input_deps = up_op.input_dependencies
assert len(input_deps) == 1
input_op = input_deps[0]
# Fuse on_start callbacks from both operators.
# This preserves deferred initialization (e.g., on_write_start for Write ops).
up_on_start = up_op._on_start
down_on_start = down_op._on_start
if up_on_start is not None and down_on_start is not None:
def fused_on_start(schema):
up_on_start(schema)
down_on_start(schema)
on_start = fused_on_start
else:
on_start = up_on_start or down_on_start
# Preserve StreamingRepartitionRefBundler if either operator has one.
# This is critical for strict-mode streaming repartition to maintain
# exact block size guarantees during further fusion.
ref_bundler = None
if isinstance(up_op._block_ref_bundler, RebundleQueue) and isinstance(
up_op._block_ref_bundler._strategy, ExactMultipleSize
):
ref_bundler = up_op._block_ref_bundler
elif isinstance(down_op._block_ref_bundler, RebundleQueue) and isinstance(
down_op._block_ref_bundler._strategy, ExactMultipleSize
):
ref_bundler = down_op._block_ref_bundler
isolate_workers = (
isinstance(up_op, TaskPoolMapOperator) and up_op.isolate_workers
) or (isinstance(down_op, TaskPoolMapOperator) and down_op.isolate_workers)
# Fused physical map operator.
assert up_op.data_context is down_op.data_context
op = MapOperator.create(
up_op.get_map_transformer().fuse(down_op.get_map_transformer()),
input_op,
up_op.data_context,
target_max_block_size_override=target_max_block_size,
name=name,
compute_strategy=compute,
min_rows_per_bundle=min_rows_per_bundled_input
if ref_bundler is None
else None,
ref_bundler=ref_bundler,
map_task_kwargs=map_task_kwargs,
ray_remote_args=ray_remote_args,
ray_remote_args_fn=ray_remote_args_fn,
on_start=on_start,
isolate_workers=isolate_workers,
)
op.set_logical_operators(*up_op._logical_operators, *down_op._logical_operators)
for map_task_kwargs_fn in itertools.chain(
up_op._map_task_kwargs_fns, down_op._map_task_kwargs_fns
):
op.add_map_task_kwargs_fn(map_task_kwargs_fn)
# Build a map logical operator to be used as a reference for further fusion.
# TODO(Scott): This is hacky, remove this once we push fusion to be purely based
# on a lower-level operator spec.
if isinstance(up_logical_op, AbstractUDFMap):
input_op = up_logical_op.input_dependencies[0]
else:
# Bottom out at the source logical op (e.g. Read()).
input_op = up_logical_op
can_modify_num_rows = (
up_logical_op.can_modify_num_rows or down_logical_op.can_modify_num_rows
)
if isinstance(down_logical_op, AbstractUDFMap):
logical_op = AbstractUDFMap(
name,
[input_op],
down_logical_op.fn,
fn_args=down_logical_op.fn_args,
fn_kwargs=down_logical_op.fn_kwargs,
fn_constructor_args=down_logical_op.fn_constructor_args,
fn_constructor_kwargs=down_logical_op.fn_constructor_kwargs,
min_rows_per_bundled_input=min_rows_per_bundled_input,
compute=compute,
can_modify_num_rows=can_modify_num_rows,
ray_remote_args_fn=ray_remote_args_fn,
ray_remote_args=ray_remote_args,
)
else:
# The downstream op is AbstractMap instead of AbstractUDFMap.
logical_op = AbstractMap(
name,
[input_op],
can_modify_num_rows=can_modify_num_rows,
min_rows_per_bundled_input=min_rows_per_bundled_input,
ray_remote_args_fn=ray_remote_args_fn,
ray_remote_args=ray_remote_args,
)
self._op_map[op] = logical_op
# Return the fused physical operator.
return op
@classmethod
def _derive_bundle_min_num_rows(
cls,
down_logical_op: AbstractMap,
up_logical_op: AbstractMap,
) -> Optional[int]:
us_bundle_min_rows_req = up_logical_op.min_rows_per_bundled_input
ds_bundle_min_rows_req = down_logical_op.min_rows_per_bundled_input
# In case neither of the ops specify `min_rows_per_bundled_input`,
# return None
if us_bundle_min_rows_req is None and ds_bundle_min_rows_req is None:
return None
# Target min bundle size is selected as max of upstream and downstream ones
# such that it could satisfy both of their requirements
return max(
ds_bundle_min_rows_req or 0,
us_bundle_min_rows_req or 0,
)
def _get_fused_all_to_all_operator(
self, down_op: AllToAllOperator, up_op: MapOperator
) -> AllToAllOperator:
assert self._can_fuse(down_op, up_op), (
"Current rule supports fusing MapOperator -> AllToAllOperator"
f", but received: {type(up_op).__name__} -> {type(down_op).__name__}"
)
# Fuse operator names.
name = up_op.name + "->" + down_op.name
down_logical_op = self._op_map.pop(down_op)
up_logical_op = self._op_map.pop(up_op)
assert isinstance(down_logical_op, AbstractAllToAll)
assert isinstance(up_logical_op, AbstractMap)
# Fuse transformation functions.
ray_remote_args = up_logical_op.ray_remote_args
down_transform_fn = down_op.get_transformation_fn()
up_map_transformer = up_op.get_map_transformer()
def fused_all_to_all_transform_fn(
blocks: List[RefBundle],
ctx: TaskContext,
) -> AllToAllTransformFnResult:
"""To fuse MapOperator->AllToAllOperator, we store the map function
in the TaskContext so that it may be used by the downstream
AllToAllOperator's transform function."""
ctx.upstream_map_transformer = up_map_transformer
ctx.upstream_map_ray_remote_args = ray_remote_args
return down_transform_fn(blocks, ctx)
# Make the upstream operator's inputs the new, fused operator's inputs.
input_deps = up_op.input_dependencies
assert len(input_deps) == 1
input_op = input_deps[0]
target_max_block_size = self._get_merged_target_max_block_size(
up_op.target_max_block_size_override, down_op.target_max_block_size_override
)
assert up_op.data_context is down_op.data_context
op = AllToAllOperator(
fused_all_to_all_transform_fn,
input_op,
up_op.data_context,
target_max_block_size_override=target_max_block_size,
num_outputs=down_op._num_outputs,
# Transfer over the existing sub-progress bars from
# the AllToAllOperator (if any) into the fused operator.
sub_progress_bar_names=down_op._sub_progress_bar_names,
name=name,
)
# Bottom out at the source logical op (e.g. Read()).
input_op = up_logical_op
if isinstance(down_logical_op, RandomShuffle):
logical_op = RandomShuffle(
name=name,
input_dependencies=[input_op],
ray_remote_args=ray_remote_args,
)
elif isinstance(down_logical_op, Repartition):
logical_op = Repartition(
num_outputs=down_logical_op.num_outputs,
input_dependencies=[input_op],
shuffle=down_logical_op.shuffle,
)
self._op_map[op] = logical_op
# Return the fused physical operator.
return op
@classmethod
def _can_fuse_map_ops(
cls,
upstream_op: AbstractMap,
downstream_op: AbstractMap,
) -> bool:
if (
cls._fuse_compute_strategy(
upstream_op.compute,
downstream_op.compute,
)
is None
):
return False
# Do not fuse Map operators in case:
#
# - Upstream could (potentially) drastically modify number of rows, while
# - Downstream has `min_rows_per_input_bundle` specified
#
# Fusing such transformations is not desirable as it could
#
# - Drastically reduce parallelism for the upstream up (for ex, if
# fusing ``Read->MapBatches(batch_size=...)`` with large enough batch-size
# could drastically reduce parallelism level of the Read op)
#
# - Potentially violate batching semantic by fusing
# ``Filter->MapBatches(batch_size=...)``
#
if (
upstream_op.can_modify_num_rows
# For historical consistency, we allow fusing `MapBatches` even if it
# can modify the number of rows. Before #60448, `MapBatches` was
# incorrectly marked as not modifying row counts, so it was always
# fused. We preserve that behavior here to avoid regressions.
#
# For the full history, see https://github.com/ray-project/ray/pull/60756.
and not isinstance(upstream_op, MapBatches)
) and downstream_op.min_rows_per_bundled_input is not None:
logger.debug(
f"Upstream operator '{upstream_op}' could be modifying # of input "
f"rows, while downstream operator '{downstream_op}' expects at least "
f"{downstream_op.min_rows_per_bundled_input} rows in a batch. "
f"Skipping fusion"
)
return False
return True
def are_op_remote_args_compatible(
up_logical_op: Union[AbstractMap, AbstractAllToAll],
down_logical_op: Union[AbstractMap, AbstractAllToAll],
) -> bool:
"""Check whether two logical ops can be fused based on their Ray remote args.
Two ops are compatible only if their ``ray_remote_args`` are mergeable and
neither op specifies a ``ray_remote_args_fn``, since the args it generates
are not known ahead of time.
"""
# Do not fuse if either op specifies a `ray_remote_args_fn`,
# since it is not known whether the generated args will be compatible.
# Only `AbstractMap` ops carry a `ray_remote_args_fn`.
for logical_op in (up_logical_op, down_logical_op):
if isinstance(logical_op, AbstractMap) and logical_op.ray_remote_args_fn:
return False
# Only fuse if the ops' remote arguments are compatible.
return are_remote_args_compatible(
up_logical_op.ray_remote_args,
down_logical_op.ray_remote_args,
)
@DeveloperAPI
def are_remote_args_compatible(
prev_args: Dict[str, Any], next_args: Dict[str, Any]
) -> bool:
"""Check if Ray remote arguments are compatible for merging."""
prev_args = _canonicalize(prev_args)
next_args = _canonicalize(next_args)
remote_args = next_args.copy()
for key in INHERITABLE_REMOTE_ARGS:
# NOTE: We only carry over inheritable value in case
# of it not being provided in the remote args
if key in prev_args and key not in remote_args:
remote_args[key] = prev_args[key]
if prev_args != remote_args:
return False
return True
def _canonicalize(remote_args: dict) -> dict:
"""Returns canonical form of given remote args."""
remote_args = remote_args.copy()
if "num_cpus" not in remote_args or remote_args["num_cpus"] is None:
remote_args["num_cpus"] = 1
if "num_gpus" not in remote_args or remote_args["num_gpus"] is None:
remote_args["num_gpus"] = 0
resources = remote_args.get("resources", {})
for k, v in list(resources.items()):
if v is None or v == 0.0:
del resources[k]
remote_args["resources"] = resources
return remote_args
@@ -0,0 +1,446 @@
import copy
from dataclasses import dataclass, is_dataclass, replace
from typing import List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorSupportsPredicatePushdown,
LogicalPlan,
PredicatePassThroughBehavior,
Rule,
)
from ray.data._internal.logical.operators import (
AbstractAllToAll,
AbstractMap,
Filter,
Join,
Limit,
Project,
RandomShuffle,
Repartition,
Union,
)
from ray.data._internal.planner.plan_expression.expression_visitors import (
_ColumnSubstitutionVisitor,
)
from ray.data.expressions import BinaryExpr, Expr, Operation, col
__all__ = [
"PredicatePushdown",
]
@dataclass(frozen=True)
class _ConvertibilitySplit:
"""Result of splitting a predicate by PyArrow convertibility.
Attributes:
convertible: The conjuncts that can be lowered to PyArrow and pushed
into the datasource. ``None`` if nothing is convertible.
residual: The conjuncts that cannot be lowered and must remain as a
``Filter``. ``None`` if everything is convertible.
"""
convertible: Optional[Expr]
residual: Optional[Expr]
class PredicatePushdown(Rule):
"""Pushes down predicates across the graph.
This rule performs the following optimizations:
1. Combines chained Filter operators with compatible expressions
2. Pushes filter expressions through eligible operators using trait-based rules
3. Pushes filters into data sources that support predicate pushdown
Eligibility is determined by the LogicalOperatorSupportsPredicatePassThrough trait, which operators
implement to declare their pushdown behavior:
- PASSTHROUGH: Filter passes through unchanged (Sort, Repartition, Shuffle, Limit)
- PASSTHROUGH_WITH_SUBSTITUTION: Filter passes through with column rebinding (Project)
- PUSH_INTO_BRANCHES: Filter is pushed into each branch (Union)
- CONDITIONAL: Filter may be pushed based on analysis (Join - analyzes which side
the predicate references and pushes to that side if safe for the join type)
"""
def apply(self, plan: LogicalPlan) -> LogicalPlan:
"""Apply predicate pushdown optimization to the logical plan."""
dag = plan.dag
new_dag = dag._apply_transform(self._try_fuse_filters)
new_dag = new_dag._apply_transform(self._try_push_down_predicate)
return LogicalPlan(new_dag, plan.context) if dag is not new_dag else plan
@classmethod
def _is_valid_filter_operator(cls, op: LogicalOperator) -> bool:
return isinstance(op, Filter) and op.is_expression_based()
@classmethod
def _try_fuse_filters(cls, op: LogicalOperator) -> LogicalOperator:
"""Fuse consecutive Filter operators with compatible expressions."""
if not cls._is_valid_filter_operator(op):
return op
input_op = op.input_dependencies[0]
if not cls._is_valid_filter_operator(input_op):
return op
# Do not fuse across a non-idempotent predicate (random/uuid/
# monotonically_increasing_id): combining moves where a predicate is
# evaluated (and thus which rows it sees), changing results.
if (
not op.predicate_expr.is_idempotent()
or not input_op.predicate_expr.is_idempotent()
):
return op
# Combine predicates
combined_predicate = op.predicate_expr & input_op.predicate_expr
# Create new filter on the input of the lower filter
return Filter(
predicate_expr=combined_predicate,
input_dependencies=[input_op.input_dependencies[0]],
)
@classmethod
def _can_push_filter_through_projection(
cls, filter_op: "Filter", projection_op: Project
) -> bool:
"""Check if a filter can be pushed through a projection operator.
Returns False (blocks pushdown) if filter references:
- Columns removed by select: select(['a']).filter(col('b'))
- Computed columns: with_column('d', 4).filter(col('d'))
- Old column names after rename: rename({'b': 'B'}).filter(col('b'))
Returns True (allows pushdown) for:
- Columns present in output: select(['a', 'b']).filter(col('a'))
- New column names after rename: rename({'b': 'B'}).filter(col('B'))
- Rename chains with name reuse: rename({'a': 'b', 'b': 'c'}).filter(col('b'))
(where 'b' is valid output created by a->b)
"""
from ray.data._internal.planner.plan_expression.expression_visitors import (
_ColumnReferenceCollector,
)
from ray.data.expressions import AliasExpr, is_rename_expr
# Do not push a filter below a projection that produces a non-idempotent
# column (random/uuid/monotonically_increasing_id): reordering changes the row
# set / position the expression is evaluated over (e.g.
# monotonically_increasing_id reassigned over the filtered subset).
if not projection_op.is_idempotent():
return False
collector = _ColumnReferenceCollector()
collector.visit(filter_op.predicate_expr)
predicate_columns = set(collector.get_column_refs() or [])
output_columns = set()
new_names = set()
original_columns_being_renamed = set()
for expr in projection_op.exprs:
if expr.name is not None:
# Collect output column names
output_columns.add(expr.name)
# Process AliasExpr (computed columns or renames)
if isinstance(expr, AliasExpr):
new_names.add(expr.name)
# Check computed column: with_column('d', 4) creates AliasExpr(lit(4), 'd')
if expr.name in predicate_columns and not is_rename_expr(expr):
return False # Computed column
# Track old names being renamed for later check
if is_rename_expr(expr):
original_columns_being_renamed.add(expr.expr.name)
# Check if filter references columns removed by explicit select.
# Valid if: projection includes all columns (star, UDF-fallback path)
# OR predicate columns exist in the explicit output set (typed path,
# where ``StarExpr`` is expanded into explicit ``col()`` refs in
# ``Project.__post_init__`` when the input schema is known).
has_required_columns = (
projection_op.has_star_expr() or predicate_columns.issubset(output_columns)
)
if not has_required_columns:
return False
# Find old names that are:
# 1. Being renamed away (in original_columns_being_renamed), AND
# 2. Referenced in predicate (in predicate_columns), AND
# 3. NOT recreated as new names (not in new_names)
#
# Examples:
# rename({'b': 'B'}).filter(col('b'))
# → {'b'} & {'b'} - {'B'} = {'b'} → BLOCKS (old name 'b' no longer exists)
#
# rename({'a': 'b', 'b': 'c'}).filter(col('b'))
# → {'a','b'} & {'b'} - {'b','c'} = {} → ALLOWS (new 'b' created by a->b)
#
# rename({'b': 'B'}).filter(col('B'))
# → {'b'} & {'B'} - {'B'} = {} → ALLOWS (using new name 'B')
invalid_old_names = (
original_columns_being_renamed & predicate_columns
) - new_names
if invalid_old_names:
return False # Old name after rename
return True
@classmethod
def _substitute_predicate_columns(
cls, predicate_expr: Expr, column_rename_map: dict[str, str]
) -> Expr:
"""Rebind column references in a predicate expression.
When pushing a predicate through a projection with column renames,
we need to rewrite column references from new names to old names.
Args:
predicate_expr: The predicate with new column names
column_rename_map: Mapping from old_name -> new_name
Returns:
The predicate rewritten to use old column names
"""
# Invert the mapping: new_name -> old_name (as col expression)
# This is because the predicate uses new names and we need to map
# them back to old names
column_mapping = {
new_col: col(old_col) for old_col, new_col in column_rename_map.items()
}
visitor = _ColumnSubstitutionVisitor(column_mapping)
return visitor.visit(predicate_expr)
@classmethod
def _combine_with_and(
cls, left: Optional[Expr], right: Optional[Expr]
) -> Optional[Expr]:
"""Combine two optional predicates with ``AND``, ignoring ``None``."""
if left is not None and right is not None:
return left & right
return left if left is not None else right
@classmethod
def _split_by_convertibility(cls, predicate: Expr) -> _ConvertibilitySplit:
"""Split a predicate into PyArrow convertible and residual parts.
Walks the top level ``AND`` chain and buckets each conjunct by whether
it can be lowered to PyArrow. The convertible part can be pushed into
the datasource while the residual part stays as a ``Filter`` above it.
Args:
predicate: The predicate expression to split.
Returns:
A ``_ConvertibilitySplit`` whose ``convertible`` and ``residual``
fields hold the two parts. Both are optional.
"""
if isinstance(predicate, BinaryExpr) and predicate.op == Operation.AND:
left = cls._split_by_convertibility(predicate.left)
right = cls._split_by_convertibility(predicate.right)
return _ConvertibilitySplit(
convertible=cls._combine_with_and(left.convertible, right.convertible),
residual=cls._combine_with_and(left.residual, right.residual),
)
if predicate._is_pyarrow_convertible():
return _ConvertibilitySplit(convertible=predicate, residual=None)
return _ConvertibilitySplit(convertible=None, residual=predicate)
@classmethod
def _try_push_down_predicate(cls, op: LogicalOperator) -> LogicalOperator:
"""Push Filter down through the operator tree."""
if not cls._is_valid_filter_operator(op):
return op
filter_op: Filter = op
input_op = filter_op.input_dependencies[0]
predicate_expr = filter_op.predicate_expr
# Case 1: Check if operator supports predicate pushdown (e.g., Read).
# The read stage never renames columns (renaming is always carried
# by an ``AliasExpr`` in a ``Project`` operator above the read), so
# the predicate above the read is already in the same column
# namespace the scanner sees — no rebinding is required here.
if (
isinstance(input_op, LogicalOperatorSupportsPredicatePushdown)
and input_op.supports_predicate_pushdown()
):
# Datasources evaluate pushed predicates via PyArrow. A predicate
# that can't be lowered to PyArrow (e.g. it contains a UDF) must
# stay as a Filter. Split the top level AND chain so the convertible
# conjuncts can still be pushed while the residual ones are kept as
# a Filter above the read.
split = cls._split_by_convertibility(predicate_expr)
if split.convertible is None:
return filter_op
result_op = input_op.apply_predicate(split.convertible)
# If the operator is unchanged (e.g., predicate references partition columns
# that can't be pushed down), keep the Filter operator
if result_op is input_op:
return filter_op
# Convertible conjuncts were pushed into the read. Re-apply any
# residual (non-convertible) conjuncts as a Filter above it.
if split.residual is None:
return result_op
return Filter(predicate_expr=split.residual, input_dependencies=[result_op])
# Datasource pushdown (Case 1) only lowers PyArrow-convertible (hence
# idempotent) conjuncts. Beyond that, do not relocate a filter whose predicate
# is non-idempotent (random/uuid/monotonically_increasing_id): pushing it
# through a pass-through operator, into Union branches, or to a Join side
# changes the row set / position the expression is evaluated over.
if not predicate_expr.is_idempotent():
return filter_op
# Case 2: Check if operator allows predicates to pass through
if isinstance(input_op, LogicalOperatorSupportsPredicatePassThrough):
behavior = input_op.predicate_passthrough_behavior()
if behavior in (
PredicatePassThroughBehavior.PASSTHROUGH,
PredicatePassThroughBehavior.PASSTHROUGH_WITH_SUBSTITUTION,
):
# Both cases push through a single input with optional column rebinding
assert len(input_op.input_dependencies) == 1, (
f"{behavior.value} operators must have exactly 1 input, "
f"got {len(input_op.input_dependencies)}"
)
# Apply column substitution if needed
if (
behavior
== PredicatePassThroughBehavior.PASSTHROUGH_WITH_SUBSTITUTION
):
# Check if we can safely push the filter through this projection
if isinstance(
input_op, Project
) and not cls._can_push_filter_through_projection(
filter_op, input_op
):
return filter_op
rename_map = input_op.get_column_substitutions()
if rename_map:
predicate_expr = cls._substitute_predicate_columns(
predicate_expr, rename_map
)
# Push filter through and recursively try to push further
new_filter = Filter(
predicate_expr=predicate_expr,
input_dependencies=[input_op.input_dependencies[0]],
)
pushed_filter = cls._try_push_down_predicate(new_filter)
# Return input_op with the pushed filter as its input
return cls._clone_op_with_new_inputs(input_op, [pushed_filter])
elif behavior == PredicatePassThroughBehavior.PUSH_INTO_BRANCHES:
# Push into each branch (e.g., Union)
# Apply filter to each branch and recursively push down
new_inputs = []
for branch_op in input_op.input_dependencies:
branch_filter = Filter(
predicate_expr=predicate_expr, input_dependencies=[branch_op]
)
pushed_branch = cls._try_push_down_predicate(branch_filter)
new_inputs.append(pushed_branch)
# Return operator with filtered branches
return cls._clone_op_with_new_inputs(input_op, new_inputs)
elif behavior == PredicatePassThroughBehavior.CONDITIONAL:
# Handle conditional pushdown (e.g., Join)
return cls._push_filter_through_conditionally(filter_op, input_op)
return filter_op
@classmethod
def _push_filter_through_conditionally(
cls, filter_op: Filter, conditional_op: LogicalOperator
) -> LogicalOperator:
"""Handle conditional pushdown for operators like Join.
For operators with multiple inputs, we can push predicates that reference
only one side down to that side, when semantically safe.
"""
# Check if operator supports conditional pushdown by having the required method
if not hasattr(conditional_op, "which_side_to_push_predicate"):
return filter_op
push_side = conditional_op.which_side_to_push_predicate(
filter_op.predicate_expr
)
if push_side is None:
# Cannot push through
return filter_op
# Use the enum value directly as branch index
branch_idx = push_side.value
# Push to the appropriate branch
new_inputs = list(conditional_op.input_dependencies)
branch_filter = Filter(
predicate_expr=filter_op.predicate_expr,
input_dependencies=[new_inputs[branch_idx]],
)
new_inputs[branch_idx] = cls._try_push_down_predicate(branch_filter)
# Return operator with updated input
return cls._clone_op_with_new_inputs(conditional_op, new_inputs)
@classmethod
def _clone_op_with_new_inputs(
cls, op: LogicalOperator, new_inputs: List[LogicalOperator]
) -> LogicalOperator:
"""Clone an operator with new inputs.
Args:
op: The operator to clone
new_inputs: List of new input operators (can be single element list)
Returns:
A shallow copy of the operator with updated input dependencies
"""
if isinstance(op, Limit):
assert len(new_inputs) == 1, len(new_inputs)
return Limit(op.limit, input_dependencies=[new_inputs[0]])
if isinstance(op, AbstractMap) and is_dataclass(op):
assert len(new_inputs) == 1, len(new_inputs)
return replace(op, input_dependencies=[new_inputs[0]])
if isinstance(op, AbstractAllToAll) and is_dataclass(op):
assert len(new_inputs) == 1, len(new_inputs)
kwargs = {"input_dependencies": [new_inputs[0]]}
if isinstance(op, Repartition):
kwargs["num_outputs"] = op.num_outputs
if isinstance(op, RandomShuffle):
kwargs["name"] = op.name
return replace(op, **kwargs)
if isinstance(op, Join) and is_dataclass(op):
assert len(new_inputs) == 2, len(new_inputs)
return Join(
new_inputs[0],
new_inputs[1],
op.join_type,
op.left_key_columns,
op.right_key_columns,
num_partitions=op.num_outputs,
left_columns_suffix=op.left_columns_suffix,
right_columns_suffix=op.right_columns_suffix,
partition_size_hint=op.partition_size_hint,
aggregator_ray_remote_args=op.aggregator_ray_remote_args,
)
if isinstance(op, Union) and is_dataclass(op):
return Union(new_inputs)
new_op = copy.copy(op)
new_op.input_dependencies = new_inputs
return new_op
@@ -0,0 +1,523 @@
from typing import Dict, List, Optional, Set, Tuple
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsProjectionPushdown,
LogicalPlan,
Rule,
)
from ray.data._internal.logical.operators import Project
from ray.data._internal.planner.plan_expression.expression_visitors import (
_ColumnReferenceCollector,
_ColumnSubstitutionVisitor,
_is_col_expr,
)
from ray.data.expressions import (
AliasExpr,
Expr,
StarExpr,
is_rename_expr,
)
__all__ = [
"ProjectionPushdown",
]
def _collect_referenced_columns(exprs: List[Expr]) -> Optional[List[str]]:
"""
Extract all column names referenced by the given expressions.
Recursively traverses expression trees to find all ColumnExpr nodes
and collects their names.
Example: For expression "col1 + col2", returns {"col1", "col2"}
"""
# ``StarExpr`` is eagerly expanded to explicit ``col()`` refs in
# ``Project.__post_init__`` when the input schema is known. So this
# branch is hit only on the UDF-fallback path (Project on top of an
# opaque-schema input like ``MapBatches``), where we can't enumerate
# columns and have to fall back to "all columns" (``None``).
if any(isinstance(expr, StarExpr) for expr in exprs):
return None
collector = _ColumnReferenceCollector()
for expr in exprs or []:
collector.visit(expr)
return collector.get_column_refs()
def _analyze_upstream_project(
upstream_project: Project,
) -> Tuple[Set[str], dict[str, Expr], Set[str]]:
"""
Analyze what the upstream project produces and identifies removed columns.
Example: Upstream exprs [col("x").alias("y")] → removed_by_renames = {"x"} if "x" not in output
"""
output_column_names = {
expr.name for expr in upstream_project.exprs if not isinstance(expr, StarExpr)
}
# Compose column definitions in the form of a mapping of
# - Target column name
# - Target expression
output_column_defs = {
expr.name: expr for expr in _filter_out_star(upstream_project.exprs)
}
# Identify upstream input columns removed by renaming (ie not propagated into
# its output)
upstream_column_renaming_map = _extract_input_columns_renaming_mapping(
upstream_project.exprs
)
return (
output_column_names,
output_column_defs,
set(upstream_column_renaming_map.keys()),
)
def _validate_fusion(
downstream_project: Project,
upstream_has_all: bool,
upstream_output_columns: Set[str],
removed_by_renames: Set[str],
) -> Tuple[bool, Set[str]]:
"""
Validate if fusion is possible without rewriting expressions.
Args:
downstream_project: The downstream Project operator
upstream_has_all: True if the upstream Project has all columns, False otherwise
upstream_output_columns: Set of column names that are available in the upstream Project
removed_by_renames: Set of column names that are removed by renames in the upstream Project
Returns:
Tuple of (is_valid, missing_columns)
- is_valid: True if all expressions can be fused, False otherwise
- missing_columns: Set of column names that are referenced but not available
Example: Downstream refs "x" but upstream renamed "x" to "y" and dropped "x"
→ (False, {"x"})
"""
missing_columns = set()
for expr in downstream_project.exprs:
if isinstance(expr, StarExpr):
continue
column_refs = _collect_referenced_columns([expr])
column_refs_set = set(column_refs or [])
columns_from_original = column_refs_set - (
column_refs_set & upstream_output_columns
)
# Validate accessibility
if not upstream_has_all and columns_from_original:
# Example: Upstream selects ["a", "b"], Downstream refs "c" → can't fuse
missing_columns.update(columns_from_original)
if any(col in removed_by_renames for col in columns_from_original):
# Example: Upstream renames "x" to "y" (dropping "x"), Downstream refs "x" → can't fuse
removed_cols = {
col for col in columns_from_original if col in removed_by_renames
}
missing_columns.update(removed_cols)
is_valid = len(missing_columns) == 0
return is_valid, missing_columns
def _would_duplicate_nonidempotent_expr(
upstream_project: Project,
downstream_project: Project,
upstream_column_defs: Dict[str, Expr],
) -> bool:
"""Return ``True`` if fusing would materialize a non-idempotent column >1 time.
Fusion inlines each upstream output column definition into every downstream
reference. For a non-idempotent definition (random/uuid/monotonically_increasing_id)
this changes its evaluation count, so we block the fusion and let the upstream
evaluate it exactly once.
Materialization count for an upstream column post-fusion is its downstream reference
multiplicity, plus one if it survives as a passthrough (the composition/star case).
"""
nonidem_cols = {
name
for name, def_expr in upstream_column_defs.items()
if not def_expr.is_idempotent()
}
if not nonidem_cols:
return False
# Downstream reference multiplicity (counts ``x + x`` as 2).
counter = _ColumnReferenceCollector()
# Note: We ignore _common_sub_exprs field as CSE rule is applied post-optimization.
# If order is to be changed, we also need to invoke the _ColumnReferenceCollector
# on _common_sub_exprs.
for e in _filter_out_star(downstream_project.exprs):
counter.visit(e)
ref_counts = counter.get_counts()
# In the composition (downstream-star) case the upstream column also survives in
# the fused output unless downstream renames it away, adding one materialization.
passthrough: Set[str] = set()
if downstream_project.has_star_expr():
renamed_away = _extract_input_columns_renaming_mapping(downstream_project.exprs)
passthrough = {
e.name
for e in upstream_project.exprs
if not isinstance(e, StarExpr) and e.name not in renamed_away
}
for name in nonidem_cols:
total = ref_counts.get(name, 0) + (1 if name in passthrough else 0)
if total > 1:
return True
return False
def _try_fuse(upstream_project: Project, downstream_project: Project) -> Project:
"""
Attempt to merge two consecutive Project operations into one.
Example: Upstream: [star(), col("x").alias("y")], Downstream: [star(), (col("y") + 1).alias("z")] → Fused: [star(), (col("x") + 1).alias("z")]
"""
# Check resource compatibility before attempting fusion
# This ensures with_column respects resource boundaries like map_batches does
from ray.data._internal.logical.rules.operator_fusion import (
FuseOperators,
are_op_remote_args_compatible,
)
# Check if remote args (num_cpus, num_gpus, etc.) are compatible and that
# neither op specifies a `ray_remote_args_fn`.
if not are_op_remote_args_compatible(upstream_project, downstream_project):
# Resources don't match - cannot fuse
return downstream_project
# Check if compute strategies are compatible
fused_compute = FuseOperators._fuse_compute_strategy(
upstream_project.compute, downstream_project.compute
)
if fused_compute is None:
# Compute strategies incompatible - cannot fuse
return downstream_project
upstream_has_star: bool = upstream_project.has_star_expr()
# TODO add validations that
# - exprs only depend on input attrs (ie no dep on output of other exprs)
# Analyze upstream
(
upstream_output_cols,
upstream_column_defs,
upstream_input_cols_removed,
) = _analyze_upstream_project(upstream_project)
# Validate fusion possibility
is_valid, missing_columns = _validate_fusion(
downstream_project,
upstream_has_star,
upstream_output_cols,
upstream_input_cols_removed,
)
if not is_valid:
# Raise KeyError to match expected error type in tests
raise KeyError(
f"Column(s) {sorted(missing_columns)} not found. "
f"Available columns: {sorted(upstream_output_cols) if not upstream_has_star else 'all columns (has star)'}"
)
if _would_duplicate_nonidempotent_expr(
upstream_project, downstream_project, upstream_column_defs
):
# Fusing would inline a non-idempotent expression (random/uuid/
# monotonically_increasing_id) into multiple references, changing its
# evaluation count. Leave the two Projects unfused so the upstream evaluates
# it exactly once and downstream reads the materialized column.
return downstream_project
# Following invariants are upheld for each ``Project`` logical op:
#
# 1. ``Project``s list of expressions are bound to op's input columns **only**
# (ie there could be no inter-dependency b/w expressions themselves)
#
# 2. `Each of expressions on the `Project``s list constitutes an output
# column definition, where column's name is derived from ``expr.name`` and
# column itself is derived by executing that expression against the op's
# input block.
#
# Therefore to abide by and satisfy aforementioned invariants, when fusing
# 2 ``Project`` operators, following scenarios are considered:
#
# 1. Composition: downstream including (and potentially renaming) upstream
# output columns (this is the case when downstream holds ``StarExpr``).
#
# 2. Projection: downstream projecting upstream output columns (by for ex,
# only selecting & transforming some of the upstream output columns).
#
# Upstream output column refs inside downstream expressions need to be bound
# to upstream output column definitions to satisfy invariant #1 (common for both
# composition/projection cases)
v = _ColumnSubstitutionVisitor(upstream_column_defs)
rebound_downstream_exprs = [
v.visit(e) for e in _filter_out_star(downstream_project.exprs)
]
if not downstream_project.has_star_expr():
# Projection case: this is when downstream is a *selection* (ie, not including
# the upstream columns with ``StarExpr``). With eager expansion of
# ``StarExpr`` in ``Project.__post_init__`` this is the common case
# for typed chains (no ``StarExpr`` reaches the optimizer).
#
# Example:
# Upstream: Project([col("a").alias("b")])
# Downstream: Project([col("b").alias("c")])
#
# Result: Project([col("a").alias("c")])
new_exprs = rebound_downstream_exprs
else:
# Composition case: downstream has ``StarExpr`` (entailing that downstream
# output will be including all of the upstream output columns). This
# is the UDF-fallback path; for typed chains
# ``Project.__post_init__`` would have replaced the ``StarExpr``
# with explicit ``col()`` refs already.
#
# Example 1:
# Upstream: [star(), col("a").alias("b")],
# Downstream: [star(), col("b").alias("c")]
#
# Result: [star(), col("a").alias("b"), col("a").alias("c")]
#
# Example 2:
# Input (columns): ["a", "b"]
# Upstream: [star({"b": "z"}), col("a").alias("x")],
# Downstream: [star({"x": "y"}), col("z")]
#
# Result: [star(), col("a").alias("y"), col("b").alias("z")]
# Extract downstream's input column rename map (downstream inputs are
# upstream's outputs)
downstream_input_column_rename_map = _extract_input_columns_renaming_mapping(
downstream_project.exprs
)
# Collect upstream output column expression "projected" to become
# downstream expressions
projected_upstream_output_col_exprs = []
# When fusing 2 projections
for e in upstream_project.exprs:
# NOTE: We have to filter out upstream output columns that are
# being *renamed* by downstream expression
if e.name not in downstream_input_column_rename_map:
projected_upstream_output_col_exprs.append(e)
new_exprs = projected_upstream_output_col_exprs + rebound_downstream_exprs
return Project(
exprs=new_exprs,
input_dependencies=[upstream_project.input_dependencies[0]],
compute=fused_compute,
ray_remote_args=downstream_project.ray_remote_args,
)
def _filter_out_star(exprs: List[Expr]) -> List[Expr]:
return [e for e in exprs if not isinstance(e, StarExpr)]
class ProjectionPushdown(Rule):
"""
Optimization rule that pushes projections (column selections) down the query plan.
This rule performs two optimizations:
1. Fuses consecutive Project operations to eliminate redundant projections
2. Pushes projections into data sources (e.g., Read operations) to enable
column pruning at the storage layer
"""
def apply(self, plan: LogicalPlan) -> LogicalPlan:
"""Apply projection pushdown optimization to the entire plan."""
dag = plan.dag
# Insert a pruning projection below consuming ops (e.g. ``Aggregate``)
# first, so the fuse/push steps can carry the narrowed columns into the
# read.
new_dag = dag._apply_transform(self._prune_aggregate_input)
new_dag = new_dag._apply_transform(self._try_fuse_projects)
new_dag = new_dag._apply_transform(self._push_projection_into_read_op)
return LogicalPlan(new_dag, plan.context) if dag is not new_dag else plan
@classmethod
def _prune_aggregate_input(cls, op: LogicalOperator) -> LogicalOperator:
"""Insert a ``Project`` below an ``Aggregate`` that keeps only the
columns it consumes (group keys + each aggregation's target column).
The aggregation drops every other column anyway, so pruning them before
the shuffle avoids dragging unused (often wide) columns through it --
which otherwise inflates both the aggregator's memory reservation and
the bytes shuffled. The inserted projection is fused/pushed toward the
read by the steps in ``apply``.
"""
from dataclasses import replace
from ray.data._internal.logical.operators.all_to_all_operator import Aggregate
from ray.data.aggregate import AggregateFnV2
from ray.data.expressions import col
if not isinstance(op, Aggregate):
return op
keys = op.key if isinstance(op.key, list) else ([op.key] if op.key else [])
required: List[str] = list(keys)
for agg in op.aggs:
# A generic ``AggregateFn`` may read arbitrary columns, so only
# prune when every aggregation declares the column it reads.
if not isinstance(agg, AggregateFnV2):
return op
target = agg.get_target_column()
if target is not None:
required.append(target)
# Order-preserving dedup; empty means nothing safe to prune to (e.g. a
# global count reading no columns).
required = list(dict.fromkeys(required))
if not required:
return op
input_op = op.input_dependencies[0]
schema = input_op.infer_schema()
if schema is None or not hasattr(schema, "names"):
return op # unknown schema: can't prove pruning helps
# Insert only when ``required`` is a strict subset of the input columns:
# this guarantees there's something to drop and keeps the rule
# idempotent (once the input yields exactly ``required`` nothing more is
# inserted, so the fixed-point optimizer terminates).
if not set(required) < set(schema.names):
return op
prune = Project(exprs=[col(c) for c in required], input_dependencies=[input_op])
return replace(op, input_dependencies=[prune])
@classmethod
def _try_fuse_projects(cls, op: LogicalOperator) -> LogicalOperator:
"""
Optimize a single Project operator.
Steps:
1. Iteratively fuse with upstream Project operations
2. Push the resulting projection into the data source if possible
"""
if not isinstance(op, Project):
return op
# Step 1: Iteratively fuse with upstream Project operations
current_project: Project = op
upstream_op = current_project.input_dependencies[0]
if not isinstance(upstream_op, Project):
return op
fused = _try_fuse(upstream_op, current_project)
return fused
@classmethod
def _push_projection_into_read_op(cls, op: LogicalOperator) -> LogicalOperator:
if not isinstance(op, Project):
return op
current_project: Project = op
# Step 2: Push projection into the data source if supported
input_op = current_project.input_dependencies[0]
if (
isinstance(input_op, LogicalOperatorSupportsProjectionPushdown)
and input_op.supports_projection_pushdown()
):
# Collect the set of input columns this ``Project`` reads.
# ``None`` means "all columns" (a ``StarExpr`` is present, so
# we can't enumerate). Renames are NOT pushed into the read:
# column renaming always stays as an ``AliasExpr`` in a
# ``Project`` on top of the read. This keeps the read stage
# in a single column namespace (the scanner's on-disk names)
# and avoids the predicate-pushdown rebinding dance.
if current_project.has_star_expr():
# UDF-fallback path: if ``StarExpr`` survives to this rule
# the input schema was unknown at construction time, so
# we can't enumerate columns and have to push "all".
required_columns = None
else:
# Otherwise, collect required columns to push projection down
# into the reader. (``Project.__post_init__`` expands
# ``StarExpr`` to explicit ``col()`` refs when the input
# schema is known, so this is the common path.)
required_columns = _collect_referenced_columns(current_project.exprs)
# Build a pure-prune projection map (identity, no renames).
projection_map = (
None
if required_columns is None
else {name: name for name in required_columns}
)
projected_input_op = input_op.apply_projection(projection_map)
# If the ``Project`` is a pure-prune (only ``col()`` refs,
# no renames, no computed expressions), the projection
# pushdown into the read op fully subsumes it — discard it.
# Otherwise (renames or computed expressions present), keep
# the ``Project`` on top so it runs above the (pruned) read.
# Physical operator fusion later merges the kept ``Project``
# into the same ``MapOperator`` as the read, so the
# runtime cost is the same either way.
has_renames = any(isinstance(e, AliasExpr) for e in current_project.exprs)
all_col_refs = all(
_is_col_expr(e) for e in _filter_out_star(current_project.exprs)
)
is_pure_prune = not has_renames and all_col_refs
if is_pure_prune:
return projected_input_op
return Project(
exprs=current_project.exprs,
input_dependencies=[projected_input_op],
compute=current_project.compute,
ray_remote_args=current_project.ray_remote_args,
)
return current_project
def _extract_input_columns_renaming_mapping(
projection_exprs: List[Expr],
) -> Dict[str, str]:
"""Fetches renaming mapping of all input columns names being renamed (replaced).
Format is source column name -> new column name.
"""
return dict(
[
_get_renaming_mapping(expr)
for expr in _filter_out_star(projection_exprs)
if is_rename_expr(expr)
]
)
def _get_renaming_mapping(expr: Expr) -> Tuple[str, str]:
assert is_rename_expr(expr)
alias: AliasExpr = expr
return alias.expr.name, alias.name
@@ -0,0 +1,148 @@
import logging
import math
from typing import Optional, Tuple, Union
from ray import available_resources as ray_available_resources
from ray.data._internal.execution.interfaces import PhysicalOperator
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
from ray.data._internal.logical.interfaces import PhysicalPlan, Rule
from ray.data._internal.logical.operators import Read
from ray.data._internal.util import _autodetect_parallelism
from ray.data.context import WARN_PREFIX, DataContext
from ray.data.datasource.datasource import Datasource, Reader
__all__ = [
"SetReadParallelismRule",
"compute_additional_split_factor",
]
logger = logging.getLogger(__name__)
def compute_additional_split_factor(
datasource_or_legacy_reader: Union[Datasource, Reader],
parallelism: int,
mem_size: int,
target_max_block_size: Optional[int],
cur_additional_split_factor: Optional[int] = None,
) -> Tuple[int, str, int, Optional[int]]:
"""Returns parallelism to use and the min safe parallelism to avoid OOMs."""
ctx = DataContext.get_current()
detected_parallelism, reason, _ = _autodetect_parallelism(
parallelism, target_max_block_size, ctx, datasource_or_legacy_reader, mem_size
)
num_read_tasks = len(
datasource_or_legacy_reader.get_read_tasks(detected_parallelism)
)
expected_block_size = None
if mem_size:
expected_block_size = mem_size / num_read_tasks
logger.debug(
f"Expected in-memory size {mem_size}, block size {expected_block_size}"
)
if target_max_block_size is None:
# Unlimited block size -> no extra splits
size_based_splits = 1
else:
size_based_splits = round(
max(1, expected_block_size / target_max_block_size)
)
else:
size_based_splits = 1
if cur_additional_split_factor:
size_based_splits *= cur_additional_split_factor
logger.debug(f"Size based split factor {size_based_splits}")
estimated_num_blocks = num_read_tasks * size_based_splits
logger.debug(f"Blocks after size splits {estimated_num_blocks}")
available_cpu_slots = ray_available_resources().get("CPU", 1)
if (
parallelism != -1
and num_read_tasks >= available_cpu_slots * 4
and num_read_tasks >= 5000
):
logger.warning(
f"{WARN_PREFIX} The requested number of read blocks of {parallelism} "
"is more than 4x the number of available CPU slots in the cluster of "
f"{available_cpu_slots}. This can "
"lead to slowdowns during the data reading phase due to excessive "
"task creation. Reduce the value to match with the available "
"CPU slots in the cluster, or set override_num_blocks to -1 for Ray Data "
"to automatically determine the number of read tasks blocks."
"You can ignore this message if the cluster is expected to autoscale."
)
# Add more output splitting for each read task if needed.
# TODO(swang): For parallelism=-1 (user did not explicitly set
# parallelism), and if the following operator produces much larger blocks,
# we should scale down the target max block size here instead of using
# splitting, which can have higher memory usage.
if estimated_num_blocks < detected_parallelism and estimated_num_blocks > 0:
k = math.ceil(detected_parallelism / estimated_num_blocks)
estimated_num_blocks = estimated_num_blocks * k
return detected_parallelism, reason, estimated_num_blocks, k
return detected_parallelism, reason, estimated_num_blocks, None
class SetReadParallelismRule(Rule):
"""
This rule sets the read op's task parallelism based on the target block
size, the requested parallelism, the number of read files, and the
available resources in the cluster.
If the parallelism is lower than requested, this rule also sets a split
factor to split the output blocks of the read task, so that the following
operator will have the desired parallelism.
"""
def apply(self, plan: PhysicalPlan) -> PhysicalPlan:
ops = [plan.dag]
while len(ops) > 0:
op = ops.pop(0)
if isinstance(op, InputDataBuffer):
continue
logical_op = plan.op_map[op]
if isinstance(logical_op, Read):
self._apply(plan, op, logical_op)
ops += op.input_dependencies
return plan
def _apply(self, plan: PhysicalPlan, op: PhysicalOperator, logical_op: Read):
estimated_in_mem_bytes = logical_op.infer_metadata().size_bytes
(
detected_parallelism,
reason,
estimated_num_blocks,
k,
) = compute_additional_split_factor(
logical_op.datasource_or_legacy_reader,
logical_op.parallelism,
estimated_in_mem_bytes,
op.target_max_block_size_override or op.data_context.target_max_block_size,
op._additional_split_factor,
)
if logical_op.parallelism == -1:
assert reason != ""
logger.debug(
f"Using autodetected parallelism={detected_parallelism} "
f"for operator {logical_op.name} to satisfy {reason}."
)
plan.op_map[op] = logical_op.set_detected_parallelism(detected_parallelism)
if k is not None:
logger.debug(
f"To satisfy the requested parallelism of {detected_parallelism}, "
f"each read task output is split into {k} smaller blocks."
)
if k is not None:
op.set_additional_split_factor(k)
logger.debug(f"Estimated num output blocks {estimated_num_blocks}")
@@ -0,0 +1,136 @@
import collections
from dataclasses import dataclass, field
from typing import Dict, Iterator, List, Optional, Set, Tuple, Type
from ray.data._internal.logical.interfaces import Rule
from ray.util.annotations import DeveloperAPI
@DeveloperAPI
class Ruleset:
"""A collection of rules to apply to a plan.
This is a utility class to ensure that, if rules depend on each other, they're
applied in a correct order.
"""
@dataclass(frozen=True)
class _Node:
rule: Type[Rule]
dependents: List["Ruleset._Node"] = field(default_factory=list)
def __init__(self, rules: Optional[List[Type[Rule]]] = None):
if rules is None:
rules = []
self._rules = list(rules)
def add(self, rule: Type[Rule]):
if rule in self._rules:
raise ValueError(f"Rule {rule} already in ruleset")
self._rules.append(rule)
if self._contains_cycle():
raise ValueError("Cannot add rule that would create a cycle")
def remove(self, rule: Type[Rule]):
if rule not in self._rules:
raise ValueError(f"Rule {rule} not found in ruleset")
self._rules.remove(rule)
def __iter__(self) -> Iterator[Type[Rule]]:
"""Iterate over the rules in this ruleset.
This method yields rules in dependency order. For example, if B depends on A,
then this method yields A before B. Each rule is yielded exactly once, and a
rule is only yielded once *all* of its dependencies have been yielded (so a
rule that several others must precede is not emitted early or duplicated).
Insertion order breaks ties among rules that are ready at the same time.
"""
order, _ = self._topological_order()
for node in order:
yield node.rule
def _topological_order(self) -> Tuple[List["Ruleset._Node"], int]:
"""Order the nodes by dependency using Kahn's algorithm.
Returns the topologically-ordered nodes and the total node count.
A node is enqueued the moment its in-degree (count of not-yet-emitted
dependencies) hits zero; since an in-degree only decreases and we
enqueue solely on the zero-crossing, each node is emitted exactly once.
Insertion order breaks ties among nodes that are ready together.
Nodes that participate in a cycle never reach in-degree zero, so they
are absent from the result -- i.e. ``len(order) < total`` exactly when
the graph contains a cycle.
"""
nodes, indegree = self._build_graph()
queue = collections.deque(n for n in nodes if indegree[id(n)] == 0)
order: List["Ruleset._Node"] = []
while queue:
node = queue.popleft()
order.append(node)
for dep in node.dependents:
indegree[id(dep)] -= 1
if indegree[id(dep)] == 0:
queue.append(dep)
return order, len(nodes)
def _build_graph(
self,
) -> Tuple[List["Ruleset._Node"], Dict[int, int]]:
"""Build the dependency DAG.
Returns the nodes (one per rule, in insertion order) and their
in-degrees -- the number of rules that must be applied before each.
The in-degree map is keyed by node identity (``id``) rather than rule
type so that distinct nodes never share a counter, and is computed as
the edges are added (every incoming edge bumps the target's in-degree)
rather than re-derived by a second traversal. A node whose in-degree
is zero is a root.
"""
rule_to_node: Dict[Type[Rule], "Ruleset._Node"] = {
rule: Ruleset._Node(rule) for rule in self._rules
}
indegree: Dict[int, int] = {id(node): 0 for node in rule_to_node.values()}
# De-duplicate edges. The same ordering can be declared from both ends
# -- rule A lists B in ``dependencies()`` while B lists A in
# ``dependents()`` -- which would otherwise add the edge twice,
# double-counting the in-degree and duplicating ``dependents`` entries.
seen_edges: Set[Tuple[int, int]] = set()
def add_edge(before: "Ruleset._Node", after: "Ruleset._Node") -> None:
"""Record that ``before`` must be applied before ``after``."""
edge = (id(before), id(after))
if edge in seen_edges:
return
seen_edges.add(edge)
before.dependents.append(after)
indegree[id(after)] += 1
for rule in self._rules:
node = rule_to_node[rule]
# Rules that must be applied *before* this rule: dependency -> node.
for dependency in rule.dependencies():
if dependency in rule_to_node:
add_edge(rule_to_node[dependency], node)
# Rules that must be applied *after* this rule: node -> dependent.
for dependent in rule.dependents():
if dependent in rule_to_node:
add_edge(node, rule_to_node[dependent])
return list(rule_to_node.values()), indegree
def _contains_cycle(self) -> bool:
# Kahn's traversal drops any node stuck in a cycle (its in-degree never
# reaches zero), so a shortfall between the ordered nodes and the total
# means a cycle exists. This correctly flags a graph that mixes an
# acyclic component with a disjoint cycle -- a plain "does any root
# exist?" check would be fooled by the acyclic root.
order, total = self._topological_order()
return len(order) != total