dongjoon-hyun commented on code in PR #59950:
URL: https://github.com/apache/spark/pull/59950#discussion_r4449347529


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PlanDynamicPruningFilters.scala:
##########
@@ -125,7 +125,12 @@ case class PlanDynamicPruningFilters(sparkSession: 
SparkSession) extends Rule[Sp
           case None =>
             val aliases = broadcastKeyIndices.map(idx =>
               Alias(buildKeys(idx), buildKeys(idx).toString)())
-            val aggregate = Aggregate(aliases, aliases, buildPlan)
+            // Alias the keys in a Project instead of making them the grouping 
expressions.
+            // Grouping on plain attributes lets the aggregate keep the build 
side's partitioning
+            // (e.g. KeyedPartitioning) instead of requiring a shuffle. See
+            // AliasAwareOutputExpression.
+            val groupingAttrs = aliases.map(_.toAttribute)
+            val aggregate = Aggregate(groupingAttrs, groupingAttrs, 
Project(aliases, buildPlan))

Review Comment:
   **1. [Low] The fallback subquery can now fail the planned-partitioning check 
of `GroupPartitionsExec`**
   
   Before this change, this subquery always had an `Exchange` above the partial 
aggregate. Now a build side that reports an ungrouped `KeyedPartitioning`, i.e. 
several splits per key, gets a `GroupPartitionsExec` here instead. That node 
checks at execution that its child still reports the partitioning it was 
planned over 
([L353-L357](https://github.com/apache/spark/blob/e82e6c3d24734c00e80806a0c37feda2402cf782/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala#L353-L357)).
   
   Two things together can break that:
   
   - The build-side scan has a subquery in its runtime filters, for example a 
nested DPP from a broadcast dimension joined to the build table, or a scalar 
subquery on its partition key. The rule that plans that subquery after 
`EnsureRequirements` rebuilds the `BatchScanExec`: 
`PlanAdaptiveDynamicPruningFilters` or `ReuseAdaptiveSubquery` with AQE, and 
`ReuseExchangeAndSubquery` without it.
   - The rebuilt node plans its input partitions again, from a `Scan` that it 
shares with the main query's copy of the build scan: 
`QueryExecution.prepareExecutedPlan` clones the logical plan, and the clone 
keeps the `Scan` instance. If the main query's scan has already pushed its 
runtime filter into that `Scan` ([`PushDownUtils.scala` 
L205](https://github.com/apache/spark/blob/e82e6c3d24734c00e80806a0c37feda2402cf782/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala#L205)),
 the rebuilt node sees fewer partitions, and the query fails with 
"GroupPartitionsExec's child no longer reports the partitioning it was planned 
over".
   
   This depends on timing, and I did not reproduce it. #60030 handles this kind 
of rebuild by sharing the planned input partitions between the copies of a 
`BatchScanExec`, so this PR adds a new place where the check can fail rather 
than a new mechanism. Could this land after #60030, or could the new test add a 
runtime filter on the keyed build side, so that the combination is covered? The 
same applies to `PlanAdaptiveDynamicPruningFilters.scala` L128.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -226,6 +228,70 @@ trait KeyGroupedPartitioningRuntimeFilterTests extends 
KeyGroupedPartitioningSui
     }
   }
 
+  test("SPARK-60741: dynamic partition filtering keeps the build side's keyed 
partitioning") {
+    withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+        SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+        SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+      val items_partitions = Array(identity("id"))
+      createTable(items, itemsColumns, items_partitions)
+      sql(s"INSERT INTO testcat.ns.$items VALUES " +
+          s"(1, 'aa', 40.0, cast('2020-01-01' as timestamp)), " +
+          s"(1, 'aa', 41.0, cast('2020-01-15' as timestamp)), " +
+          s"(2, 'bb', 10.0, cast('2020-01-01' as timestamp)), " +
+          s"(2, 'bb', 10.5, cast('2020-01-01' as timestamp)), " +
+          s"(3, 'cc', 15.5, cast('2020-02-01' as timestamp))")
+
+      val purchases_partitions = Array(identity("item_id"))
+      createTable(purchases, purchasesColumns, purchases_partitions)
+      sql(s"INSERT INTO testcat.ns.$purchases VALUES " +
+          s"(1, 42.0, cast('2020-01-01' as timestamp)), " +
+          s"(1, 44.0, cast('2020-01-15' as timestamp)), " +
+          s"(1, 45.0, cast('2020-01-15' as timestamp)), " +
+          s"(2, 11.0, cast('2020-01-01' as timestamp)), " +
+          s"(3, 19.5, cast('2020-02-01' as timestamp))")
+
+      // The false arm exercises PlanDynamicPruningFilters and the true arm
+      // PlanAdaptiveDynamicPruningFilters.
+      Seq(false, true).foreach { aqeEnabled =>
+        withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> 
aqeEnabled.toString) {
+          val df = sql(s"SELECT sum(p.price) FROM testcat.ns.$items i, " +
+              s"testcat.ns.$purchases p WHERE i.id = p.item_id AND i.price > 
40.0")
+          checkAnswer(df, Seq(Row(131)))
+
+          // The subquery groups only on the build side's key column, which is 
a partition key of
+          // `items`, so its aggregate must keep the scan's keyed 
partitioning. These keys repeat
+          // across splits, so keeping the partitioning means a 
`GroupPartitionsExec` coalesces
+          // them, and no shuffle is needed.
+          val plan = df.queryExecution.executedPlan
+          val subqueries = collectScans(plan)
+            .flatMap(_.runtimeFilters)
+            .flatMap(_.collect { case d: DynamicPruningExpression => d })
+            .flatMap(_.collect { case i: InSubqueryExec => i.plan })
+          assert(subqueries.nonEmpty, s"expected a DPP subquery, got:\n$plan")
+          subqueries.foreach {
+            case subquery: SubqueryExec =>
+              val subPlan = subquery.child

Review Comment:
   **3. [Nit] The AQE arm does not check that the subquery is adaptive**
   
   The comment at L255-L256 says that the true arm exercises 
`PlanAdaptiveDynamicPruningFilters`, but the assertions accept any 
`SubqueryExec`. If AQE stopped applying to this query, 
`InsertAdaptiveSparkPlan` would only log a warning and return the plan without 
AQE, both arms would go through `PlanDynamicPruningFilters`, and the test would 
still pass. `PlanDynamicPruningFilters` never builds an adaptive subquery, so 
one assertion pins the arm, with `AdaptiveSparkPlanExec` added to the 
`adaptive` import at L55:
   
   ```suggestion
                 val subPlan = subquery.child
                 assert(subPlan.isInstanceOf[AdaptiveSparkPlanExec] == 
aqeEnabled,
                   s"the DPP subquery should be adaptive only with 
AQE:\n$subPlan")
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PlanDynamicPruningFilters.scala:
##########
@@ -125,7 +125,12 @@ case class PlanDynamicPruningFilters(sparkSession: 
SparkSession) extends Rule[Sp
           case None =>
             val aliases = broadcastKeyIndices.map(idx =>
               Alias(buildKeys(idx), buildKeys(idx).toString)())
-            val aggregate = Aggregate(aliases, aliases, buildPlan)
+            // Alias the keys in a Project instead of making them the grouping 
expressions.
+            // Grouping on plain attributes lets the aggregate keep the build 
side's partitioning
+            // (e.g. KeyedPartitioning) instead of requiring a shuffle. See
+            // AliasAwareOutputExpression.

Review Comment:
   **4. [Nit] The comment does not say why an aliased grouping key loses the 
partitioning**
   
   `AliasAwareOutputExpression` names the catalyst trait that only builds the 
alias map. The partitioning is carried through the alias by 
`PartitioningPreservingUnaryExecNode` in sql/core, which `ProjectExec` mixes 
in. The comment also leaves out why the old shape lost it: a partial 
aggregate's `resultExpressions` hold only the grouping attributes 
(`AggUtils.planAggregateWithoutDistinct`), so an `Alias` among the grouping 
expressions never reaches its `outputExpressions`. Without that, someone could 
move the alias back into the grouping, and only the two new tests would notice 
the shuffle. Maybe something like this, here and at 
`PlanAdaptiveDynamicPruningFilters.scala` L123-L126:
   
   ```suggestion
               // A partial aggregate outputs only its grouping attributes, so 
an Alias among the
               // grouping expressions hides the build side's partitioning 
(e.g. KeyedPartitioning)
               // from PartitioningPreservingUnaryExecNode, and 
EnsureRequirements adds a shuffle.
               // Alias the keys in a Project and group on its attributes 
instead, the shape that
               // PullOutGroupingExpressions gives complex grouping expressions.
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/PlanAdaptiveDynamicPruningFilters.scala:
##########
@@ -120,7 +120,12 @@ case class PlanAdaptiveDynamicPruningFilters(
           case None =>
             val aliases = indices.map(idx =>
               Alias(buildKeys(idx), buildKeys(idx).toString)())
-            val aggregate = Aggregate(aliases, aliases, buildPlan)
+            // Alias the keys in a Project instead of making them the grouping 
expressions.
+            // Grouping on plain attributes lets the aggregate keep the build 
side's partitioning
+            // (e.g. KeyedPartitioning) instead of requiring a shuffle. See
+            // AliasAwareOutputExpression.
+            val groupingAttrs = aliases.map(_.toAttribute)
+            val aggregate = Aggregate(groupingAttrs, groupingAttrs, 
Project(aliases, buildPlan))

Review Comment:
   **5. [Nit] The new block and its comment are duplicated in both rules**
   
   These eight lines, the comment included, repeat 
`PlanDynamicPruningFilters.scala` L126-L133 word for word. The block has 
drifted before: SPARK-46946 had to change both copies, and SPARK-52974 brought 
the non-AQE copy on par with this one. If a later change reaches only one copy, 
the two paths plan different subqueries again, and only one of them keeps the 
build side's partitioning. Could one helper build this logical plan for both 
rules, for example next to 
`DynamicPruningSubquery.usableBroadcastValueProjection`, which both paths 
already use?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/PlanAdaptiveDynamicPruningFilters.scala:
##########
@@ -120,7 +120,12 @@ case class PlanAdaptiveDynamicPruningFilters(
           case None =>
             val aliases = indices.map(idx =>
               Alias(buildKeys(idx), buildKeys(idx).toString)())
-            val aggregate = Aggregate(aliases, aliases, buildPlan)
+            // Alias the keys in a Project instead of making them the grouping 
expressions.
+            // Grouping on plain attributes lets the aggregate keep the build 
side's partitioning
+            // (e.g. KeyedPartitioning) instead of requiring a shuffle. See
+            // AliasAwareOutputExpression.
+            val groupingAttrs = aliases.map(_.toAttribute)
+            val aggregate = Aggregate(groupingAttrs, groupingAttrs, 
Project(aliases, buildPlan))

Review Comment:
   **2. [Low] AQE can no longer split a skewed build-side join inside the 
subquery**
   
   When the build side is itself a shuffled join on the DPP key, for example 
`fact f JOIN (SELECT d1.k FROM d1 JOIN d2 ON d1.k = d2.k WHERE d2.x < 5) t ON 
f.p = t.k`, the `Project` now carries the join's hash partitioning to the 
aggregate, and the subquery no longer has an `Exchange` of its own:
   
   ```
   Before: HashAggregate(Final) <- Exchange <- HashAggregate(Partial) <- 
SortMergeJoin
   After:  HashAggregate(Complete) <- Project <- SortMergeJoin
   ```
   
   `OptimizeSkewedJoin` reads a split skewed partition with 
`UnknownPartitioning`, which no longer satisfies the aggregate's 
`ClusteredDistribution`. So `ValidateRequirements` fails, and the rule returns 
the original plan 
([L259-L275](https://github.com/apache/spark/blob/e82e6c3d24734c00e80806a0c37feda2402cf782/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/OptimizeSkewedJoin.scala#L259-L275)).
 A local shuffle read, after AQE turns the join into a broadcast hash join, is 
rejected the same way 
([L239-L254](https://github.com/apache/spark/blob/e82e6c3d24734c00e80806a0c37feda2402cf782/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala#L239-L254)).
 The pruned fact scan then waits on an unsplit skewed task that master avoided. 
`spark.sql.adaptive.forceOptimizeSkewedJoin` does not help much: 
`CombineAdjacentAggregation` has already merged the two aggregates, so the 
shuffle that it adds moves the join output before any deduplication.
   
   `SELECT DISTINCT d1.k FROM d1 JOIN d2 ON d1.k = d2.k` makes the same 
trade-off on master, so this is not a bug. Could the code comment or the PR 
description mention it? Keeping the old shape when `buildPlan` contains a 
shuffled join would also avoid it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to