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 f88a554fafde [SPARK-58210][SQL][FOLLOWUP] Extend 
CombineAdjacentAggregation to partial merge
f88a554fafde is described below

commit f88a554fafde5eec35c06af4c24e3e1df6cda313
Author: Wenchen Fan <[email protected]>
AuthorDate: Tue Aug 11 10:34:25 2026 +0800

    [SPARK-58210][SQL][FOLLOWUP] Extend CombineAdjacentAggregation to partial 
merge
    
    ### What changes were proposed in this pull request?
    
    This is a follow-up to #57363. It extends `CombineAdjacentAggregation` to 
combine adjacent
    `PartialMerge` and `Final` hash aggregates into one `Final` aggregate. This 
pattern can be
    produced by physical-plan extensions that add an aggregation stage; Spark's 
built-in query
    planning does not currently produce it.
    
    The combined aggregate preserves the upper `Final` aggregate's child 
distribution requirement.
    Because the lower aggregate is removed, the combined aggregate takes its 
streaming and
    shuffle-partition metadata from that lower aggregate and reads its original 
child. Filtered
    `Partial` aggregation remains supported, while a `PartialMerge` pair with 
filters is not combined.
    
    ### Why are the changes needed?
    
    `CombineAdjacentAggregation` currently handles only `Partial` followed by 
`Final`. A compatible
    adjacent `PartialMerge` and `Final` hash aggregate pair can also be 
collapsed safely, avoiding an
    unnecessary hash aggregation stage.
    
    The upper aggregate's distribution requirement must remain after the rule 
runs so that later AQE
    optimization does not split partitions that the combined aggregate requires 
to remain clustered.
    The lower aggregate's streaming and shuffle-partition metadata remain 
child-facing properties and
    therefore move to the combined aggregate when the lower node is removed.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Spark may produce a single final hash aggregate instead of adjacent 
partial-merge and final
    hash aggregates when their grouping and logical lineage are compatible. 
Query results are
    unchanged.
    
    ### How was this patch tested?
    
    Added coverage to `CombineAdjacentAggregationSuite` for:
    
    - combining an executable `PartialMerge` and `Final` hash aggregate 
pipeline and comparing the
      uncombined and combined results;
    - preserving the upper distribution requirement and lower streaming, 
shuffle-partition,
      grouping, child, and buffer-offset metadata;
    - refusing to combine a filtered `PartialMerge` pair;
    - retaining the distribution requirement under AQE and verifying that 
shuffle partition specs
      are present but are not partial reducer specs.
    
    Ran:
    
    ```
    build/sbt 'sql/testOnly 
org.apache.spark.sql.execution.CombineAdjacentAggregationSuite' sql/scalastyle
    ```
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Codex (GPT-5)
    
    Closes #57859 from cloud-fan/cloud-fan/consolidate-adjacent-aggregation.
    
    Authored-by: Wenchen Fan <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../sql/execution/CombineAdjacentAggregation.scala | 64 +++++++++++++---
 .../CombineAdjacentAggregationSuite.scala          | 88 +++++++++++++++++++++-
 2 files changed, 140 insertions(+), 12 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/CombineAdjacentAggregation.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/CombineAdjacentAggregation.scala
index 00643e1638ff..f9d841615d42 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/CombineAdjacentAggregation.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/CombineAdjacentAggregation.scala
@@ -17,13 +17,15 @@
 
 package org.apache.spark.sql.execution
 
-import org.apache.spark.sql.catalyst.expressions.aggregate.{Complete, Final, 
Partial}
+import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
Complete, Final, Partial, PartialMerge}
 import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.execution.aggregate.{BaseAggregateExec, 
HashAggregateExec, ObjectHashAggregateExec, SortAggregateExec}
 import org.apache.spark.sql.internal.SQLConf
 
 /**
- * This rule combines adjacent aggregation with `Partial` and `Final` to 
`Complete` mode.
+ * This rule combines adjacent aggregation with `Partial` and `Final` to 
`Complete` mode. For
+ * [[HashAggregateExec]], it also combines `PartialMerge` and `Final` to 
`Final` mode. The latter
+ * can be produced by physical plan extensions that add an extra aggregation 
stage.
  * Example for hash aggregate:
  *    HashAggregate (Final)         HashAggregate (Complete)
  *          |                             |
@@ -43,19 +45,20 @@ import org.apache.spark.sql.internal.SQLConf
  * It supports [[HashAggregateExec]], [[SortAggregateExec]] and 
[[ObjectHashAggregateExec]].
  */
 object CombineAdjacentAggregation extends Rule[SparkPlan] {
+  private case class CombinedAggregate(
+      aggregateExpressions: Seq[AggregateExpression],
+      initialInputBufferOffset: Int)
+
   override def apply(plan: SparkPlan): SparkPlan = {
     if (!conf.getConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED)) {
       return plan
     }
 
     plan.transformDown {
-      case finalAgg @ HashAggregateExec(_, _, _, _, _, _, _, _, partialAgg: 
HashAggregateExec)
-          if isPartialAgg(partialAgg, finalAgg) =>
-        finalAgg.copy(
-          groupingExpressions = partialAgg.groupingExpressions,
-          aggregateExpressions = 
partialAgg.aggregateExpressions.map(_.copy(mode = Complete)),
-          initialInputBufferOffset = 0,
-          child = partialAgg.child)
+      case finalAgg @ HashAggregateExec(_, _, _, _, _, _, _, _, partialAgg: 
HashAggregateExec) =>
+        combinedAggregate(partialAgg, finalAgg)
+          .map(combineHashAggregates(partialAgg, finalAgg, _))
+          .getOrElse(finalAgg)
 
       case finalAgg @ SortAggregateExec(_, _, _, _, _, _, _, _, partialAgg: 
SortAggregateExec)
           if isPartialAgg(partialAgg, finalAgg) =>
@@ -76,6 +79,41 @@ object CombineAdjacentAggregation extends Rule[SparkPlan] {
     }
   }
 
+  private def combineHashAggregates(
+      partialAgg: HashAggregateExec,
+      finalAgg: HashAggregateExec,
+      combined: CombinedAggregate): HashAggregateExec = {
+    // Keep the final aggregate's distribution requirement because the rule 
runs after
+    // EnsureRequirements. The other child-facing metadata comes from the 
removed aggregate.
+    finalAgg.copy(
+      isStreaming = partialAgg.isStreaming,
+      numShufflePartitions = partialAgg.numShufflePartitions,
+      groupingExpressions = partialAgg.groupingExpressions,
+      aggregateExpressions = combined.aggregateExpressions,
+      initialInputBufferOffset = combined.initialInputBufferOffset,
+      child = partialAgg.child)
+  }
+
+  private def combinedAggregate(
+      partialAgg: HashAggregateExec,
+      finalAgg: HashAggregateExec): Option[CombinedAggregate] = {
+    if (!isCompatibleAggregates(partialAgg, finalAgg)) {
+      None
+    } else if (partialAgg.aggregateExpressions.forall(_.mode == Partial)) {
+      Some(CombinedAggregate(
+        partialAgg.aggregateExpressions.map(_.copy(mode = Complete)),
+        initialInputBufferOffset = 0))
+    } else if (partialAgg.aggregateExpressions.forall(_.mode == PartialMerge) 
&&
+        partialAgg.aggregateExpressions.forall(_.filter.isEmpty) &&
+        finalAgg.aggregateExpressions.forall(_.filter.isEmpty)) {
+      Some(CombinedAggregate(
+        finalAgg.aggregateExpressions,
+        partialAgg.initialInputBufferOffset))
+    } else {
+      None
+    }
+  }
+
   /**
    * Check if `partialAgg` is the partial aggregate of `finalAgg`.
    */
@@ -83,7 +121,13 @@ object CombineAdjacentAggregation extends Rule[SparkPlan] {
       partialAgg: BaseAggregateExec,
       finalAgg: BaseAggregateExec): Boolean = {
     partialAgg.aggregateExpressions.forall(_.mode == Partial) &&
-      finalAgg.aggregateExpressions.forall(_.mode == Final) &&
+      isCompatibleAggregates(partialAgg, finalAgg)
+  }
+
+  private def isCompatibleAggregates(
+      partialAgg: BaseAggregateExec,
+      finalAgg: BaseAggregateExec): Boolean = {
+    finalAgg.aggregateExpressions.forall(_.mode == Final) &&
       partialAgg.groupingExpressions.map(_.canonicalized) ==
         finalAgg.groupingExpressions.map(_.canonicalized) &&
       finalAgg.logicalLink.isDefined &&
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/CombineAdjacentAggregationSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/CombineAdjacentAggregationSuite.scala
index 17675705060d..bd970f1c34d8 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/CombineAdjacentAggregationSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/CombineAdjacentAggregationSuite.scala
@@ -18,8 +18,9 @@
 package org.apache.spark.sql.execution
 
 import org.apache.spark.sql.{QueryTest, Row}
-import org.apache.spark.sql.catalyst.expressions.aggregate.{Complete, Final}
-import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.catalyst.expressions.aggregate.{Complete, Final, 
PartialMerge}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanHelper, 
AQEShuffleReadExec}
 import org.apache.spark.sql.execution.aggregate.{BaseAggregateExec, 
HashAggregateExec, SortAggregateExec}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
@@ -191,6 +192,89 @@ class CombineAdjacentAggregationSuite extends QueryTest
     }
   }
 
+  test("Combine adjacent partial merge and final hash aggregates") {
+    withTempView("t") {
+      spark.range(20).selectExpr("id % 3 as k", "id % 7 as 
v").createOrReplaceTempView("t")
+      val query = "SELECT k, count(*) FROM (SELECT /*+ repartition(k) */ * 
FROM t) GROUP BY k"
+      val finalAgg = withSQLConf(
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+          SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "false") {
+        collect(sql(query).queryExecution.executedPlan) {
+          case agg: HashAggregateExec if 
agg.child.isInstanceOf[HashAggregateExec] => agg
+        }.head
+      }
+      val partialAgg = finalAgg.child.asInstanceOf[HashAggregateExec]
+      val partialMergeInput = InputAdapter(partialAgg)
+      val partialMergeAgg = partialAgg.copy(
+        requiredChildDistributionExpressions = 
Some(partialAgg.groupingExpressions),
+        isStreaming = true,
+        numShufflePartitions = Some(5),
+        initialInputBufferOffset = finalAgg.initialInputBufferOffset,
+        aggregateExpressions = partialAgg.aggregateExpressions.map(_.copy(mode 
= PartialMerge)),
+        child = partialMergeInput)
+      partialMergeAgg.copyTagsFrom(partialAgg)
+      val finalOverPartialMerge = finalAgg.copy(child = partialMergeAgg)
+      finalOverPartialMerge.copyTagsFrom(finalAgg)
+
+      val combined = CombineAdjacentAggregation(finalOverPartialMerge)
+        .asInstanceOf[HashAggregateExec]
+      assert(combined.aggregateExpressions.forall(_.mode == Final))
+      assert(combined.requiredChildDistributionExpressions ==
+        finalAgg.requiredChildDistributionExpressions)
+      assert(combined.isStreaming == partialMergeAgg.isStreaming)
+      assert(combined.numShufflePartitions == 
partialMergeAgg.numShufflePartitions)
+      assert(combined.groupingExpressions == 
partialMergeAgg.groupingExpressions)
+      assert(combined.aggregateAttributes == finalAgg.aggregateAttributes)
+      assert(combined.resultExpressions == finalAgg.resultExpressions)
+      assert(combined.initialInputBufferOffset == 
partialMergeAgg.initialInputBufferOffset)
+      assert(combined.child == partialMergeInput)
+      assert(finalOverPartialMerge.executeCollect().map(_.copy()).toSet ==
+        combined.executeCollect().map(_.copy()).toSet)
+
+      val filteredPartialMerge = partialMergeAgg.copy(
+        aggregateExpressions = 
partialMergeAgg.aggregateExpressions.zipWithIndex.map {
+          case (agg, 0) => agg.copy(filter = Some(Literal.TrueLiteral))
+          case (agg, _) => agg
+        })
+      filteredPartialMerge.copyTagsFrom(partialAgg)
+      val finalOverFilteredPartialMerge = finalAgg.copy(child = 
filteredPartialMerge)
+      finalOverFilteredPartialMerge.copyTagsFrom(finalAgg)
+      
assert(collect(CombineAdjacentAggregation(finalOverFilteredPartialMerge)) {
+        case agg: HashAggregateExec => agg
+      }.size == 3)
+    }
+  }
+
+  test("Combined aggregate retains its distribution requirement under AQE") {
+    import testImplicits._
+
+    withTempView("t") {
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => (if (i > 4) 5 else i, i.toString)), 3)
+        .toDF("k", "v").createOrReplaceTempView("t")
+
+      withSQLConf(
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_OPTIMIZE_SKEWS_IN_REBALANCE_PARTITIONS_ENABLED.key 
-> "true",
+          SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "true",
+          SQLConf.COALESCE_PARTITIONS_MIN_PARTITION_NUM.key -> "1",
+          SQLConf.SHUFFLE_PARTITIONS.key -> "5",
+          SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "150") {
+        val df = sql(
+          "SELECT k, count(*) FROM (SELECT /*+ REBALANCE(k) */ * FROM t) GROUP 
BY k")
+        checkAnswer(df, Seq(Row(1, 1), Row(2, 1), Row(3, 1), Row(4, 1), Row(5, 
6)))
+        assert(collect(df.queryExecution.executedPlan) {
+          case agg: HashAggregateExec => agg
+        }.size == 1)
+        val partitionSpecs = collect(df.queryExecution.executedPlan) {
+          case read: AQEShuffleReadExec => read
+        }.flatMap(_.partitionSpecs)
+        assert(partitionSpecs.nonEmpty)
+        
assert(partitionSpecs.forall(!_.isInstanceOf[PartialReducerPartitionSpec]))
+      }
+    }
+  }
+
   test("Do not combine when a shuffle sits between the partial and final 
aggregate") {
     withTempView("t") {
       spark.range(20).selectExpr("id % 3 as k", "id % 7 as v")


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

Reply via email to