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 5b5e0f6fe1aa [SPARK-54593][SQL][FOLLOWUP] Require a statistics-based
benefit, not the fallback ratio, for materialized-input DPP
5b5e0f6fe1aa is described below
commit 5b5e0f6fe1aa488d2de170b18c41c82918373e5f
Author: Wenchen Fan <[email protected]>
AuthorDate: Mon Jun 29 09:34:27 2026 +0800
[SPARK-54593][SQL][FOLLOWUP] Require a statistics-based benefit, not the
fallback ratio, for materialized-input DPP
### What changes were proposed in this pull request?
Follow-up to https://github.com/apache/spark/pull/56071 (SPARK-54593),
which made an already-materialized filtering side (a `LocalRelation` or a
`checkpoint()` / `localCheckpoint()`-derived `LogicalRDD`) eligible for dynamic
partition pruning (DPP), and to https://github.com/apache/spark/pull/56636,
which re-framed that structural eligibility as a recompute-cost guard
(`isCheaplyRecomputableMaterializedPlan`).
Such a side has no selective predicate, so it should obtain DPP only
through **broadcast reuse** or a **genuine statistics-based benefit** -- not
through `pruningHasBenefit`'s no-statistics fallback ratio, which is meant for
"stats missing, but there is a predicate likely to be selective". This PR:
- Gates the fallback ratio on `hasSelectivePredicate`. A materialized-only
side with no usable statistics reports no benefit (broadcast-reuse only)
instead of being injected as a standalone, always-applied subquery on a guessed
ratio.
- Always honors a statistics-based ratio. When the per-column statistic is
absent, a materialized side's exact `maxRows` is used as a conservative upper
bound on the join-key NDV, so a small, selective materialized side still
establishes a real benefit and keeps standalone DPP.
This builds on the structural eligibility predicates already on master
(`hasSelectivePredicate` / `isCheaplyRecomputableMaterializedPlan`) and changes
only the benefit estimate in `pruningHasBenefit`.
### Why are the changes needed?
With `spark.sql.optimizer.dynamicPartitionPruning.reuseBroadcastOnly =
false`, a materialized side that prunes little or nothing (no selective
predicate, no usable statistics) was injected as an always-applied subquery
that re-executes the filtering side for no benefit. Before SPARK-54593 such a
side was not DPP-eligible at all, so this is a regression in master. The
broadcast-reuse path (the feature's intended use) and genuinely beneficial
standalone DPP (proven by statistics, includ [...]
### Does this PR introduce _any_ user-facing change?
No. It only avoids planning a no-benefit dynamic partition pruning subquery
for a materialized build side; query results are unchanged.
### How was this patch tested?
New unit test in `DynamicPartitionPruningSuite` ("DPP materialized-input
eligibility requires an estimable pruning benefit"), with `reuseBroadcastOnly =
false` and no reusable broadcast:
- a small `LocalRelation` (no column statistics) keeps standalone DPP via
its exact `maxRows` bound;
- a checkpoint-derived `LogicalRDD` with no retained statistics and no
`maxRows` is **not** injected as a standalone subquery;
- an opaque (`mapPartitions`-derived) side is not eligible, with or without
a sibling broadcast.
Existing positive tests for materialized filtering sides (broadcast reuse)
continue to pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Anthropic Claude Opus)
Closes #56603 from cloud-fan/SPARK-54593-followup.
Authored-by: Wenchen Fan <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../org/apache/spark/sql/internal/SQLConf.scala | 5 +-
.../dynamicpruning/PartitionPruning.scala | 83 ++++++---
.../spark/sql/DynamicPartitionPruningSuite.scala | 194 ++++++++++++++++++---
3 files changed, 227 insertions(+), 55 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index ff2dd2dbd483..2d4d9a75c374 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -700,7 +700,10 @@ object SQLConf {
.doc("When statistics are not available or configured not to be used,
this config will be " +
"used as the fallback filter ratio for computing the data size of the
partitioned table " +
"after dynamic partition pruning, in order to evaluate if it is worth
adding an extra " +
- "subquery as the pruning filter if broadcast reuse is not applicable.")
+ "subquery as the pruning filter if broadcast reuse is not applicable.
This fallback " +
+ "ratio only applies when the filtering side has a selective predicate;
a materialized " +
+ "filtering side without one obtains dynamic partition pruning through
broadcast reuse " +
+ "only.")
.version("3.0.0")
.doubleConf
.createWithDefault(0.5)
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 25de90c1c15f..3e1b39b564c7 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
@@ -111,7 +111,7 @@ object PartitionPruning extends Rule[LogicalPlan] with
PredicateHelper with Join
"since there are no usage for multiple broadcasting keys at the moment.")
val indices = Seq(joinKeys.indexOf(filteringKeys.head))
lazy val hasBenefit = pruningHasBenefit(
- pruningKey, partScan, filteringKeys.head, filteringPlan)
+ pruningKey, partScan, filteringKeys.head, filteringPlan,
hasSelectivePredicate(filteringPlan))
if (reuseEnabled || hasBenefit) {
// insert a DynamicPruning wrapper to identify the subquery during query
planning
Filter(
@@ -134,45 +134,74 @@ object PartitionPruning extends Rule[LogicalPlan] with
PredicateHelper with Join
* in bytes of the plan on the other side of the join. We estimate the
filtering ratio
* using column statistics if they are available, otherwise we use the
config value of
* `spark.sql.optimizer.dynamicPartitionPruning.fallbackFilterRatio`.
+ *
+ * The fallback ratio is only meaningful "when CBO stats are missing, but
there is a predicate
+ * that is likely to be selective" -- so it is used only when
`hasSelectivePredicate` is true. A
+ * filtering side that is eligible only because it is already materialized
(a LocalRelation or a
+ * checkpoint-derived LogicalRDD, SPARK-54593) carries no such predicate;
for it we rely solely on
+ * the statistics-based ratio and report no benefit when statistics are
unavailable, so it is not
+ * injected as a standalone always-applied subquery on a guessed ratio. A
statistics-based ratio,
+ * when available, is always honored regardless of `hasSelectivePredicate`.
*/
private def pruningHasBenefit(
partExpr: Expression,
partPlan: LogicalPlan,
otherExpr: Expression,
- otherPlan: LogicalPlan): Boolean = {
+ otherPlan: LogicalPlan,
+ hasSelectivePredicate: Boolean): Boolean = {
// get the distinct counts of an attribute for a given table
def distinctCounts(attr: Attribute, plan: LogicalPlan): Option[BigInt] = {
plan.stats.attributeStats.get(attr).flatMap(_.distinctCount)
}
- // the default filtering ratio when CBO stats are missing, but there is a
- // predicate that is likely to be selective
- val fallbackRatio = conf.dynamicPartitionPruningFallbackFilterRatio
- // the filtering ratio based on the type of the join condition and on the
column statistics
- val filterRatio = (partExpr.references.toList,
otherExpr.references.toList) match {
- // filter out expressions with more than one attribute on any side of
the operator
- case (leftAttr :: Nil, rightAttr :: Nil)
- if conf.dynamicPartitionPruningUseStats =>
- // get the CBO stats for each attribute in the join condition
- val partDistinctCount = distinctCounts(leftAttr, partPlan)
- val otherDistinctCount = distinctCounts(rightAttr, otherPlan)
- val availableStats = partDistinctCount.isDefined &&
partDistinctCount.get > 0 &&
- otherDistinctCount.isDefined
- if (!availableStats) {
- fallbackRatio
- } else if (partDistinctCount.get.toDouble <=
otherDistinctCount.get.toDouble) {
- // there is likely an estimation error, so we fallback
- fallbackRatio
- } else {
- 1 - otherDistinctCount.get.toDouble /
partDistinctCount.get.toDouble
- }
- case _ => fallbackRatio
+ // the filtering ratio derived from column statistics, when reliable stats
are available
+ val statsBasedRatio: Option[Double] =
+ (partExpr.references.toList, otherExpr.references.toList) match {
+ // filter out expressions with more than one attribute on any side of
the operator
+ case (leftAttr :: Nil, rightAttr :: Nil)
+ if conf.dynamicPartitionPruningUseStats =>
+ // get the CBO stats for each attribute in the join condition
+ val partDistinctCount = distinctCounts(leftAttr, partPlan)
+ // A materialized filtering side (e.g. a LocalRelation) may carry
no column statistics
+ // but an exact `maxRows`, which is a conservative upper bound on
its join-key NDV. Use
+ // it when the column statistic is missing so a small, selective
materialized side still
+ // yields a statistics-based ratio rather than falling through to
the gated fallback.
+ // Restrict this to a cheaply-recomputable materialized side: DPP
re-evaluates the
+ // filtering side, so deriving a pruning benefit from the
`maxRows` of an opaque plan
+ // (e.g. one with a `mapPartitions`, which
`isCheaplyRecomputableMaterializedPlan`
+ // rejects) could inject a standalone subquery that prunes a
partition the join's
+ // re-evaluation then needs, giving wrong results for a
hidden-non-deterministic side.
+ val otherDistinctCount =
+ distinctCounts(rightAttr, otherPlan).orElse {
+ if (isCheaplyRecomputableMaterializedPlan(otherPlan)) {
+ otherPlan.maxRows.map(BigInt(_))
+ } else {
+ None
+ }
+ }
+ val availableStats = partDistinctCount.isDefined &&
partDistinctCount.get > 0 &&
+ otherDistinctCount.isDefined
+ if (!availableStats) {
+ None
+ } else if (partDistinctCount.get.toDouble <=
otherDistinctCount.get.toDouble) {
+ // there is likely an estimation error, so there is no reliable
stats-based ratio
+ None
+ } else {
+ Some(1 - otherDistinctCount.get.toDouble /
partDistinctCount.get.toDouble)
+ }
+ case _ => None
+ }
+
+ // Without a reliable stats-based ratio, fall back to the configured ratio
only when there is a
+ // predicate likely to be selective; otherwise there is no evidence of a
pruning benefit.
+ val filterRatio = statsBasedRatio.orElse {
+ if (hasSelectivePredicate)
Some(conf.dynamicPartitionPruningFallbackFilterRatio) else None
}
- val estimatePruningSideSize = filterRatio *
partPlan.stats.sizeInBytes.toFloat
- val overhead = calculatePlanOverhead(otherPlan)
- estimatePruningSideSize > overhead
+ filterRatio.exists { ratio =>
+ ratio * partPlan.stats.sizeInBytes.toFloat >
calculatePlanOverhead(otherPlan)
+ }
}
/**
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 de44286593b9..4aa4a8870b77 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
@@ -1766,6 +1766,108 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
}
}
+ test("SPARK-54593: a materialized filtering side is not injected as a
standalone DPP subquery " +
+ "without an estimated pruning benefit") {
+ // Broadcast joins are disabled, so the DPP filter cannot reuse a
broadcast exchange. A
+ // materialized filtering side carries no selective predicate, so its
pruning benefit cannot be
+ // estimated; it must therefore not be injected as an always-applied
standalone subquery (which
+ // would re-execute the filtering side and prune nothing for a
non-selective side).
+ withSQLConf(SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ withTable("events") {
+ Seq((1, "hour1", "a"), (2, "hour1", "b"), (3, "hour2", "a"))
+ .toDF("id", "hour", "category")
+ .write
+ .partitionBy("hour", "category")
+ .format(tableFormat)
+ .mode("overwrite")
+ .saveAsTable("events")
+
+ // A materialized filtering side that covers every partition: it
offers no pruning benefit.
+ val sampledKeys = Seq("hour1||a", "hour1||b",
"hour2||a").toDF("hc_key")
+
assert(sampledKeys.queryExecution.optimizedPlan.exists(_.isInstanceOf[LocalRelation]))
+
+ val events = spark.table("events").as("events")
+ val sampled = sampledKeys.as("sampled")
+ val df = events
+ .join(sampled, concat_ws("||", $"events.hour", $"events.category")
=== $"sampled.hc_key")
+ .select($"events.id")
+
+ checkPartitionPruningPredicate(df, withSubquery = false, withBroadcast
= false)
+ checkAnswer(df, Row(1) :: Row(2) :: Row(3) :: Nil)
+ }
+ }
+ }
+
+ test("SPARK-54593: a materialized filtering side keeps statistics-backed
standalone DPP") {
+ // A checkpoint-derived LogicalRDD has no Filter but can retain column
statistics. When those
+ // statistics establish a pruning benefit, DPP must still be injected as a
standalone subquery
+ // when no broadcast can be reused -- the materialized-input handling
gates only the
+ // no-statistics fallback ratio, not the statistics-based benefit.
+ withSQLConf(
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_USE_STATS.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ // The fallback ratio offers no benefit, so DPP can only come from the
statistics-based ratio.
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "0",
+ // No broadcast can be reused, so an injected filter must be a
standalone subquery.
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "false",
+ SQLConf.CBO_ENABLED.key -> "true") {
+ withTable("dim_one") {
+ // A dimension with a single distinct store_id, with column statistics
computed.
+ spark.range(20).selectExpr("1 AS store_id")
+ .write.format(tableFormat).saveAsTable("dim_one")
+ sql("ANALYZE TABLE dim_one COMPUTE STATISTICS FOR COLUMNS store_id")
+
+ // Checkpoint a projection of it: a LogicalRDD with no Filter that
retains the NDV (= 1).
+ val keys =
spark.table("dim_one").select("store_id").localCheckpoint(eager = true)
+ val keysPlan = keys.queryExecution.optimizedPlan
+ assert(keysPlan.exists {
+ case r: LogicalRDD => r.isCheckpointedInput
+ case _ => false
+ })
+
assert(keysPlan.stats.attributeStats.values.exists(_.distinctCount.contains(BigInt(1))),
+ s"checkpointed side should retain the NDV statistic:\n$keysPlan")
+
+ // fact_stats is partitioned by store_id (NDV > 1) and has column
statistics, so the
+ // statistics-based ratio establishes a pruning benefit for this
materialized side.
+ val df = spark.table("fact_stats").join(keys,
"store_id").select("date_id")
+
+ checkPartitionPruningPredicate(df, withSubquery = true, withBroadcast
= false)
+ }
+ }
+ }
+
+ test("SPARK-54593: a small materialized filtering side keeps standalone DPP
via its row bound") {
+ // A LocalRelation has no column statistics but an exact maxRows, a
conservative upper bound on
+ // its join-key NDV. A small, selective materialized side must still get
standalone DPP when no
+ // broadcast can be reused -- the row bound, not the gated fallback ratio,
supplies the benefit.
+ withSQLConf(
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_USE_STATS.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ // The fallback ratio offers no benefit, so DPP can only come from the
row-bound ratio.
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "0",
+ // No broadcast can be reused, so an injected filter must be a
standalone subquery.
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "false",
+ SQLConf.CBO_ENABLED.key -> "true") {
+ // A one-row LocalRelation: no column statistics, but maxRows = 1 bounds
the join-key NDV.
+ val keys = Seq(0).toDF("store_id")
+ val keysPlan = keys.queryExecution.optimizedPlan
+ assert(keysPlan.exists(_.isInstanceOf[LocalRelation]))
+ assert(keysPlan.maxRows.contains(1))
+
assert(!keysPlan.stats.attributeStats.values.exists(_.distinctCount.isDefined),
+ s"LocalRelation should not carry a distinct-count
statistic:\n$keysPlan")
+
+ // fact_stats is partitioned by store_id (NDV > 1) and has column
statistics, so the row bound
+ // (NDV <= 1) establishes a strong pruning benefit for this materialized
side.
+ val df = spark.table("fact_stats").join(keys,
"store_id").select("date_id")
+
+ checkPartitionPruningPredicate(df, withSubquery = true, withBroadcast =
false)
+ }
+ }
+
test("DPP does not treat a non-checkpointed LogicalRDD as a selective
filtering side") {
withSQLConf(SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "true") {
@@ -1840,11 +1942,17 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
}
}
- test("DPP materialized-input eligibility requires a cheaply recomputable
plan") {
+ test("DPP materialized-input eligibility requires an estimable pruning
benefit") {
+ // A materialized filtering side is injected as a standalone DPP subquery
only when it has an
+ // estimable pruning benefit. With CBO statistics on the partitioned table
(the partition-side
+ // NDV) and a small row bound on the filtering side (a LocalRelation's
exact maxRows), the
+ // benefit comes from the statistics-based ratio. The fallback ratio is no
longer applied to a
+ // side without a selective predicate, so it is pinned to 0 to keep it out
of the picture.
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",
- SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "1000",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_USE_STATS.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "0",
+ SQLConf.CBO_ENABLED.key -> "true",
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
SQLConf.EXCHANGE_REUSE_ENABLED.key -> "false",
SQLConf.SUBQUERY_REUSE_ENABLED.key -> "false",
@@ -1859,6 +1967,10 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
.mode("overwrite")
.saveAsTable("events")
+ // Column statistics on the partitioned table supply the
partition-side NDV used to estimate
+ // the pruning benefit for a materialized filtering side.
+ sql("ANALYZE TABLE events COMPUTE STATISTICS FOR COLUMNS p")
+
def activeDppSubqueries(df: DataFrame): Seq[InSubqueryExec] = {
collectDynamicPruningExpressions(df.queryExecution.executedPlan)
.collect { case in: InSubqueryExec => in }
@@ -1871,7 +1983,7 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
assert(activeDppSubqueries(df).exists {
case InSubqueryExec(_, _: SubqueryExec, _, _, _, _) => true
case _ => false
- }, s"Should execute standalone DPP for a cheaply recomputable
materialized plan:\n" +
+ }, s"Should execute standalone DPP for a materialized plan with an
estimable benefit:\n" +
df.queryExecution)
}
@@ -1880,33 +1992,20 @@ 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 an opaque materialized plan:\n" +
+ s"Shouldn't execute standalone DPP for this filtering side:\n" +
df.queryExecution)
}
+ // A LocalRelation is cheaply recomputable and exposes an exact
maxRows, a conservative
+ // bound on its join-key NDV, so its pruning benefit is estimable and
it gets a standalone
+ // DPP subquery.
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")))
+ // A checkpoint-derived LogicalRDD is also cheaply recomputable, but a
checkpointed
+ // LocalRelation retains no column statistics and exposes no maxRows,
so its pruning benefit
+ // cannot be estimated; with no broadcast to reuse it is not injected
as a DPP subquery at
+ // all. The statistics-backed checkpointed case (retained NDV) is
covered by a dedicated
+ // test.
+ checkNoDpp(Seq(1).toDF("p").localCheckpoint(eager = true))
val checkpointed = Seq(1).toDS().localCheckpoint(eager = true)
val mappedKeys = checkpointed.mapPartitions { values =>
@@ -1915,6 +2014,14 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
}.toDF("p")
checkNoDpp(mappedKeys)
+ // An opaque side becomes DPP-eligible through a selective predicate,
and a `limit` gives it
+ // an exact maxRows. That maxRows must not be a pruning-benefit NDV
bound for a side that is
+ // not cheaply recomputable: a standalone subquery would re-evaluate
the stateful
+ // mapPartitions, prune the fact to the first key, and drop the rows
the join's second
+ // evaluation needs. With the maxRows bound gated on
isCheaplyRecomputableMaterializedPlan,
+ // no benefit is estimated, no standalone DPP is injected, and the
result stays correct.
+ checkNoDpp(mappedKeys.filter($"p" > 0).limit(1))
+
withSQLConf(SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true") {
val broadcastJoin =
spark.table("events").join(broadcast(mappedKeys),
Seq("p")).select("p")
@@ -1924,6 +2031,39 @@ abstract class DynamicPartitionPruningV1Suite extends
DynamicPartitionPruningDat
s"Shouldn't trigger DPP for an opaque broadcast plan:\n" +
broadcastJoin.queryExecution)
+ // The cheaply-recomputable structural branches of
isCheaplyRecomputableMaterializedPlan
+ // are exercised through broadcast reuse: a checkpointed input
carries no statistics and
+ // no maxRows, so it has no estimable benefit and only the
eligibility decision (not the
+ // benefit term) determines whether the broadcast is reused for DPP.
+ val cpKeys = Seq(1).toDF("p").localCheckpoint(eager = true)
+
+ def checkBroadcastReuseEligible(side: DataFrame): Unit = {
+ checkPartitionPruningPredicate(
+ spark.table("events").join(broadcast(side),
Seq("p")).select("p"),
+ withSubquery = false, withBroadcast = true)
+ }
+
+ def checkBroadcastReuseIneligible(side: DataFrame): Unit = {
+ checkPartitionPruningPredicate(
+ spark.table("events").join(broadcast(side),
Seq("p")).select("p"),
+ withSubquery = false, withBroadcast = false)
+ }
+
+ // A non-selective Filter/Project above a materialized input stays
eligible: its recompute
+ // cost is dominated by the materialized leaf, so it reuses the
broadcast for DPP even
+ // though the boolean-cast filter is not classified selective by
isLikelySelective.
+
checkBroadcastReuseEligible(cpKeys.filter($"p".cast("boolean")).select($"p"))
+
+ // An Aggregate over a materialized input is excluded: its
shuffle/compute cost is
+ // invisible to the scan-bytes cost model, so it is not DPP-eligible
even with a reusable
+ // broadcast.
+
checkBroadcastReuseIneligible(cpKeys.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.
+ val identityUdf = udf((x: Int) => x)
+
checkBroadcastReuseIneligible(cpKeys.select(identityUdf($"p").as("p")))
+
val target = spark.table("events").hint("merge")
.join(mappedKeys.hint("merge"), Seq("p"))
.select($"p", lit("target").as("branch"))
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]