This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 25a949f18572 [SPARK-54593][SQL][FOLLOWUP] Frame materialized-input DPP
eligibility as a recompute-cost guard
25a949f18572 is described below
commit 25a949f1857214b06cbaacc29a8090d7e97a3469
Author: Wenchen Fan <[email protected]>
AuthorDate: Mon Jun 22 11:41:29 2026 -0700
[SPARK-54593][SQL][FOLLOWUP] Frame materialized-input DPP eligibility as a
recompute-cost guard
### What changes were proposed in this pull request?
Follow-up to #56535 (SPARK-54593). That PR let a filtering side without a
selective predicate become DPP-eligible when it is built from an
already-materialized input (a `LocalRelation`, or a checkpoint-derived
`LogicalRDD`), restricting the operators above the materialized leaf to a
narrow set (`Project`/`Filter`/`Union`/`SubqueryAlias`, additionally requiring
expressions to be deterministic and free of subqueries/UDFs). That restriction
was documented as ensuring the plan is "repeatable".
This PR re-frames the check (`isRepeatableMaterializedPlan` ->
`isCheaplyRecomputableMaterializedPlan`) around the reason the restriction
actually exists -- **recompute cost** -- and drops the parts that were really a
piecemeal repeatability guard:
- The operator allowlist is kept and justified on cost grounds. A subquery
in a `Project`/`Filter` is still excluded, re-justified as cost (it embeds its
own plan, whose recompute cost `calculatePlanOverhead` cannot see).
- The expression-level determinism / UDF / generator check is **removed**
(see below).
- The tests from #56535 are kept, and two cases are added that pin the
operators-above cost behavior.
### Why are the changes needed?
"Repeatability" was the wrong justification. DPP evaluates the filtering
side independently from the join (a standalone subquery, or a reused broadcast
versus a shuffled join input), so it has *always* assumed the filtering side is
repeatable, on every eligibility path including the selective-predicate one.
Materialization does not add a repeatability guarantee, and the allowlist did
not establish one in general.
What materialization actually provides is the **cost** counterpart to a
selective predicate. A selective predicate is evidence of a high pruning ratio
(the benefit term of `pruningHasBenefit`); an already-materialized input makes
the side ~free to re-read (the cost term, `calculatePlanOverhead`), so even a
modest pruning ratio is worthwhile. That cost claim only holds when the
operators above the materialized leaf are dominated by their input's scan bytes
-- exactly what `calculatePla [...]
Repeatability is deliberately **not** checked here. Whether re-evaluating
the filtering side yields the same rows is a pre-existing, DPP-wide concern
(the selective-predicate path carries it too -- a non-deterministic operator
over any filtering side can produce different keys on re-evaluation), and it
should be addressed by a system-level design rather than patched piecemeal on
the materialized path. Accordingly the expression-level determinism / UDF check
is removed; a non-determini [...]
### Does this PR introduce _any_ user-facing change?
No. DPP is an optimization; results are unchanged for repeatable filtering
sides, which is the supported case.
### How was this patch tested?
Existing `DynamicPartitionPruning*Suite`s, including the two tests from
#56535. Added two cases to the materialized-input eligibility test: a cheap
`filter`/`select` chain over a checkpoint stays eligible (standalone DPP), and
an `Aggregate` over a checkpoint is excluded (its shuffle cost is invisible to
the cost model).
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Anthropic Claude Opus)
Closes #56636 from cloud-fan/dpp-materialized-input-followup.
Authored-by: Wenchen Fan <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../spark/sql/execution/SparkOptimizer.scala | 8 ++-
.../dynamicpruning/PartitionPruning.scala | 79 +++++++++++++++-------
.../spark/sql/DynamicPartitionPruningSuite.scala | 31 +++++++--
3 files changed, 87 insertions(+), 31 deletions(-)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
index e27faf7b4d9e..1b3b2d3efc72 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
@@ -112,7 +112,13 @@ class SparkOptimizer(
V2ScanRelationPushDown.ruleName,
V2ScanPartitioningAndOrdering.ruleName,
V2Writes.ruleName,
- ReplaceCTERefWithRepartition.ruleName)
+ ReplaceCTERefWithRepartition.ruleName,
+ // CleanupDynamicPruningFilters finalizes the DPP predicates inserted by
PartitionPruning --
+ // notably rewriting non-deterministic ones to `true` so they are not
re-evaluated. That is
+ // correctness behavior, not an optional optimization, so the rule must
not be excludable.
+ // Disabling DPP is done by excluding PartitionPruning (the inserter),
after which this rule
+ // is a no-op.
+ CleanupDynamicPruningFilters.ruleName)
/**
* Optimization batches that are executed before the regular optimization
batches (also before
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala
index 93e388c45af0..25de90c1c15f 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala
@@ -205,38 +205,66 @@ object PartitionPruning extends Rule[LogicalPlan] with
PredicateHelper with Join
}
/**
- * Returns whether a plan can be evaluated repeatedly from materialized
inputs and produce the
- * same rows.
+ * Returns whether the filtering side is cheap enough to recompute that DPP
is worthwhile even
+ * without a selective predicate: its cost is dominated by an
already-materialized input, with
+ * only scan-cost-bound operators above it.
*
- * LocalRelation rows are already locally available. A checkpoint-derived
LogicalRDD establishes
- * an explicit checkpoint boundary and can be used as a broadcast build side
for DPP without
- * evaluating the computation upstream of that boundary again.
+ * This is the cost-side counterpart to `hasSelectivePredicate`. A selective
predicate is
+ * evidence of a high pruning ratio (the benefit term of
`pruningHasBenefit`); an
+ * already-materialized input is the complementary signal on the cost term
-- a `LocalRelation`
+ * (rows already local) or a checkpoint-derived `LogicalRDD`
(`isCheckpointedInput` requires the
+ * RDD to be actually checkpointed, so a lazy checkpoint does not qualify)
is ~free to re-read,
+ * so even a modest pruning ratio clears the benefit bar. `InMemoryRelation`
is excluded because
+ * cache()/persist() are lazy: its presence does not guarantee the data has
been materialized,
+ * and missing or evicted blocks may require recomputing the upstream plan.
*
- * InMemoryRelation is intentionally excluded because cache() and persist()
are lazy: its
- * presence does not guarantee the cached data has been materialized, and
missing or evicted
- * blocks may require evaluating the upstream computation again.
+ * The operators above the materialized input are restricted to ones whose
cost is dominated by
+ * their input's scan bytes -- the only cost `calculatePlanOverhead` can
see. `Project`/`Filter`
+ * add negligible compute, a `Union`'s cost is the sum of its (materialized)
children, and
+ * `SubqueryAlias` is a no-op. `Aggregate`, joins, and opaque RDD operators
(e.g. `mapPartitions`)
+ * are excluded: they add compute or a shuffle the scan-bytes cost model
cannot see, so treating
+ * such a side as a cheap materialized input would overstate the pruning
benefit. A `Project`/
+ * `Filter` is likewise excluded when its expressions embed a subquery
(which carries its own
+ * plan) or an opaque user function (a UDF or a user-defined generator) --
both add recompute
+ * cost `calculatePlanOverhead` does not account for.
*
- * The supported operators are intentionally narrow. DPP is optional, and
logical-plan
- * determinism does not cover user functions stored outside Catalyst
expressions.
+ * This is primarily a cost guard, but the eligible shapes are also
repeatable in practice, which
+ * matters because DPP duplicates the filtering side and must produce the
same keys on
+ * re-evaluation. Honest non-determinism does not slip through: a `rand()`
(or a UDF marked
+ * non-deterministic) above the materialized input makes the resulting
`DynamicPruningSubquery`
+ * non-deterministic (`PlanExpression.deterministic` folds in its build
plan), so
+ * `CleanupDynamicPruningFilters` rewrites the dynamic predicate to `true`
before physical
+ * planning rather than planning a standalone `SubqueryExec` -- it is never
re-evaluated. That
+ * rule is non-excludable (`SparkOptimizer.nonExcludableRules`), so this
holds regardless of
+ * `spark.sql.optimizer.excludedRules`. The
+ * residual, DPP-wide limitation is *hidden* non-determinism left marked
deterministic; the
+ * opaque-expression exclusion above narrows it, and the rest is
intentionally left to a future
+ * system-level design rather than patched piecemeal here. The one
materialized-input-specific
+ * repeatability concern -- a checkpoint that has not been materialized yet
-- is handled by
+ * `LogicalRDD.isCheckpointedInput` requiring the RDD to be actually
checkpointed.
*/
- private def isRepeatableMaterializedPlan(plan: LogicalPlan): Boolean = {
- def isRepeatableExpression(expression: Expression): Boolean = {
- expression.deterministic && !SubqueryExpression.hasSubquery(expression)
&&
- !expression.exists {
- case _: NonSQLExpression | _: UserDefinedExpression | _:
UserDefinedGenerator => true
- case _ => false
- }
+ private def isCheaplyRecomputableMaterializedPlan(plan: LogicalPlan):
Boolean = {
+ // An expression keeps the side cheap only if its cost is bounded by the
input scan that
+ // `calculatePlanOverhead` measures. A subquery embeds its own plan, and
an opaque user
+ // function (a Scala/Python UDF, a user-defined generator, or any other
non-Catalyst
+ // expression) adds CPU/IO the scan-bytes cost model cannot see --
recomputing either would
+ // cost more than the materialized leaf suggests, so it disqualifies the
side.
+ def isScanCostBoundExpression(e: Expression): Boolean = {
+ !SubqueryExpression.hasSubquery(e) && !e.exists {
+ case _: NonSQLExpression | _: UserDefinedExpression | _:
UserDefinedGenerator => true
+ case _ => false
+ }
}
plan match {
case _: LocalRelation => true
case r: LogicalRDD => r.isCheckpointedInput
- case Project(projectList, child) if
projectList.forall(isRepeatableExpression) =>
- isRepeatableMaterializedPlan(child)
- case Filter(condition, child) if isRepeatableExpression(condition) =>
- isRepeatableMaterializedPlan(child)
- case u: Union => u.children.forall(isRepeatableMaterializedPlan)
- case SubqueryAlias(_, child) => isRepeatableMaterializedPlan(child)
+ case Project(projectList, child) if
projectList.forall(isScanCostBoundExpression) =>
+ isCheaplyRecomputableMaterializedPlan(child)
+ case Filter(condition, child) if isScanCostBoundExpression(condition) =>
+ isCheaplyRecomputableMaterializedPlan(child)
+ case u: Union => u.children.forall(isCheaplyRecomputableMaterializedPlan)
+ case SubqueryAlias(_, child) =>
isCheaplyRecomputableMaterializedPlan(child)
case _ => false
}
}
@@ -245,10 +273,11 @@ object PartitionPruning extends Rule[LogicalPlan] with
PredicateHelper with Join
* To be able to prune partitions on a join key, the filtering side needs to
* meet the following requirements:
* (1) it can not be a stream
- * (2) it needs to contain a selective predicate or have a repeatable
materialized input
+ * (2) it needs to contain a selective predicate or a cheaply-recomputable
materialized input
*/
private def hasPartitionPruningFilter(plan: LogicalPlan): Boolean = {
- !plan.isStreaming && (hasSelectivePredicate(plan) ||
isRepeatableMaterializedPlan(plan))
+ !plan.isStreaming &&
+ (hasSelectivePredicate(plan) ||
isCheaplyRecomputableMaterializedPlan(plan))
}
private def prune(plan: LogicalPlan): LogicalPlan = {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/DynamicPartitionPruningSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/DynamicPartitionPruningSuite.scala
index 4db67ec77479..de44286593b9 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/DynamicPartitionPruningSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/DynamicPartitionPruningSuite.scala
@@ -1840,7 +1840,7 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
}
}
- test("DPP materialized-input eligibility requires a repeatable plan") {
+ test("DPP materialized-input eligibility requires a cheaply recomputable
plan") {
withSQLConf(SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
SQLConf.DYNAMIC_PARTITION_PRUNING_USE_STATS.key -> "false",
@@ -1871,7 +1871,7 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
assert(activeDppSubqueries(df).exists {
case InSubqueryExec(_, _: SubqueryExec, _, _, _, _) => true
case _ => false
- }, s"Should execute standalone DPP for a repeatable materialized
plan:\n" +
+ }, s"Should execute standalone DPP for a cheaply recomputable
materialized plan:\n" +
df.queryExecution)
}
@@ -1880,13 +1880,34 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
DppMaterializedInputTestState.reset(counterId)
assert(df.collect().toSeq === Seq(Row(1)))
assert(activeDppSubqueries(df).isEmpty,
- s"Shouldn't trigger DPP for a non-repeatable materialized plan:\n"
+
+ s"Shouldn't trigger DPP for an opaque materialized plan:\n" +
df.queryExecution)
}
checkStandaloneDpp(Seq(1).toDF("p"))
checkStandaloneDpp(Seq(1).toDF("p").localCheckpoint(eager = true))
+ // Cheap, scan-cost-bound operators above a materialized input stay
eligible: their
+ // recompute cost is dominated by the materialized leaf that
calculatePlanOverhead sees.
+ // The filter uses a non-selective predicate (a boolean cast is not
classified selective by
+ // isLikelySelective), so eligibility must come from
isCheaplyRecomputableMaterializedPlan
+ // rather than the selective-predicate path.
+ checkStandaloneDpp(Seq(1).toDF("p").localCheckpoint(eager = true)
+ .filter($"p".cast("boolean")).select($"p"))
+
+ // An Aggregate over a materialized input is excluded even though it
is repeatable: its
+ // shuffle/compute cost is invisible to the scan-bytes cost model, so
treating it as a cheap
+ // materialized input would overstate the pruning benefit.
+ checkNoDpp(
+ Seq(1).toDF("p").localCheckpoint(eager =
true).groupBy("p").count().select("p"))
+
+ // A UDF projection over a materialized input is excluded: an opaque
user function adds
+ // CPU/IO the scan-bytes cost model cannot see, so it would be
recomputed without that cost
+ // being counted against the pruning benefit.
+ val identityUdf = udf((x: Int) => x)
+ checkNoDpp(
+ Seq(1).toDF("p").localCheckpoint(eager =
true).select(identityUdf($"p").as("p")))
+
val checkpointed = Seq(1).toDS().localCheckpoint(eager = true)
val mappedKeys = checkpointed.mapPartitions { values =>
val key = DppMaterializedInputTestState.next(counterId)
@@ -1900,7 +1921,7 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
DppMaterializedInputTestState.reset(counterId)
assert(broadcastJoin.collect().toSeq === Seq(Row(1)))
assert(activeDppSubqueries(broadcastJoin).isEmpty,
- s"Shouldn't trigger DPP for a non-repeatable broadcast plan:\n" +
+ s"Shouldn't trigger DPP for an opaque broadcast plan:\n" +
broadcastJoin.queryExecution)
val target = spark.table("events").hint("merge")
@@ -1916,7 +1937,7 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
assert(rows.size === 1)
assert(rows.head.getString(1) === "target")
assert(activeDppSubqueries(withSiblingBroadcast).isEmpty,
- s"A sibling broadcast shouldn't make a non-repeatable plan
eligible for DPP:\n" +
+ s"A sibling broadcast shouldn't make an opaque plan eligible for
DPP:\n" +
withSiblingBroadcast.queryExecution)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]