524 lines
20 KiB
Python
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
|