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

524 lines
20 KiB
Python

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