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 @@
"""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}")