Files
2026-07-13 13:17:40 +08:00

276 lines
12 KiB
Python

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