Copilot commented on code in PR #13254:
URL: https://github.com/apache/gluten/pull/13254#discussion_r4313383483


##########
gluten-substrait/src/main/scala/org/apache/gluten/extension/joinagg/PushAggregateThroughJoin.scala:
##########
@@ -593,19 +619,19 @@ case class PushAggregateThroughJoin(spark: SparkSession)
     expr.collect { case attr: Attribute => attr }
   }
 
-  sealed private trait JoinSide {
+  sealed trait JoinSide {
     def plan(join: Join): LogicalPlan
     def outputSet(join: Join): AttributeSet
     def replace(join: Join, newPlan: LogicalPlan): Join
   }
 
-  private case object JoinLeft extends JoinSide {
+  case object JoinLeft extends JoinSide {
     override def plan(join: Join): LogicalPlan = join.left

Review Comment:
   This PR makes previously-private internals (`JoinSide`, 
`JoinLeft/JoinRight`, and `pushPartialAggToJoinSide`) part of the public 
surface area of `PushAggregateThroughJoin` (even if nested). If this is 
intended only for tests, consider reducing visibility (e.g., 
`private[joinagg]`) and placing the unit tests under 
`org.apache.gluten.extension.joinagg` so they can access package-private 
members, or annotate the method as test-only and keep the `JoinSide` helpers 
non-public to avoid expanding the supported API unintentionally.



##########
gluten-substrait/src/main/scala/org/apache/gluten/extension/joinagg/PushAggregateThroughJoin.scala:
##########
@@ -125,135 +130,170 @@ case class PushAggregateThroughJoin(spark: SparkSession)
     // Any join-specific key widening or subtree rebuild is handled later by 
pushOnce /
     // pushPartialAggToJoinSide. This keeps the split step reusable even when 
the current child is
     // not yet a join-shaped subtree.
+    if (hasDistinctAggExpr(agg.aggregateExpressions)) {
+      return None
+    }
     val pushableSpecs = collectPushableSpecs(agg.aggregateExpressions)
-    if (pushableSpecs.isEmpty) {
-      None
-    } else {
-      val groupingRefAttrs = 
dedupeAttrs(agg.groupingExpressions.flatMap(referencedAttrsInOrder))
-      // Aggregate subexpressions that are not rewritten remain above the 
lower aggregate after the
-      // split. Their referenced attrs must therefore still be produced by the 
lower aggregate, but
-      // at this stage we only derive that requirement from the aggregate 
itself, not from any
-      // particular child shape.
-      val nonPushableAggAttrs = dedupeAttrs(agg.aggregateExpressions.flatMap {
-        case Alias(expr, _) if !isPushableExpr(expr) => 
referencedAttrsInOrder(expr)
-        case expr if !isPushableExpr(expr) => referencedAttrsInOrder(expr)
-        case _ => Nil
-      })
-      val partialGroupingKeys = dedupeAttrs(groupingRefAttrs ++ 
nonPushableAggAttrs)
-      val partialAliases = pushableSpecs.zipWithIndex.map {
-        case (spec, idx) =>
-          Alias(
-            JoinAggregateFunctionWrapper
-              .wrapperPartial(spec.aggregate, spec.wrapperKey)
-              .toAggregateExpression(),
-            s"_pushed_${spec.aggregate.prettyName}_buffer_$idx"
-          )()
-      }
+    if (
+      pushableSpecs.isEmpty &&
+      !isGroupingOnly(agg.groupingExpressions, agg.aggregateExpressions)
+    ) {
+      return None
+    }
+    val groupingRefAttrs = 
dedupeAttrs(agg.groupingExpressions.flatMap(referencedAttrsInOrder))
+    // Aggregate subexpressions that are not rewritten remain above the lower 
aggregate after the
+    // split. Their referenced attrs must therefore still be produced by the 
lower aggregate, but
+    // at this stage we only derive that requirement from the aggregate 
itself, not from any
+    // particular child shape.
+    val nonPushableAggAttrs = dedupeAttrs(agg.aggregateExpressions.flatMap {
+      case Alias(expr, _) if !isPushableExpr(expr) => 
referencedAttrsInOrder(expr)
+      case expr if !isPushableExpr(expr) => referencedAttrsInOrder(expr)
+      case _ => Nil
+    })
+    val partialGroupingKeys = dedupeAttrs(groupingRefAttrs ++ 
nonPushableAggAttrs)
+    val partialAliases = pushableSpecs.zipWithIndex.map {
+      case (spec, idx) =>
+        Alias(
+          JoinAggregateFunctionWrapper
+            .wrapperPartial(spec.aggregate, spec.wrapperKey)
+            .toAggregateExpression(),
+          s"_pushed_${spec.aggregate.prettyName}_buffer_$idx"
+        )()
+    }
 
-      val partialAgg = Aggregate(
+    val partialAgg = withPhase(
+      Aggregate(
         groupingExpressions = partialGroupingKeys,
         aggregateExpressions = partialGroupingKeys ++ partialAliases,
-        child = agg.child
-      )
-
-      val partialOutputAttrs =
-        partialAgg.output
-          .slice(partialGroupingKeys.size, partialGroupingKeys.size + 
partialAliases.size)
-          .map(cleanAttr)
-      val partialRefs = pushableSpecs.zip(partialOutputAttrs).map {
-        case (spec, attr) => SidePartialRef(spec, attr)
-      }
+        child = agg.child),
+      PartialPhase)
+
+    val partialOutputAttrs =
+      partialAgg.output
+        .slice(partialGroupingKeys.size, partialGroupingKeys.size + 
partialAliases.size)
+        .map(cleanAttr)
+    val partialRefs = pushableSpecs.zip(partialOutputAttrs).map {
+      case (spec, attr) => SidePartialRef(spec, attr)
+    }
 
-      rewriteAggregateExpressions(agg.aggregateExpressions, partialRefs).map {
-        rewrittenAggExprs =>
-          (agg.copy(aggregateExpressions = rewrittenAggExprs, child = 
partialAgg), partialAgg)
-      }
+    rewriteAggregateExpressions(agg.aggregateExpressions, partialRefs).map {
+      rewrittenAggExprs =>
+        val finalAgg = withPhase(
+          agg.copy(aggregateExpressions = rewrittenAggExprs, child = 
partialAgg),
+          FinalPhase)
+        (finalAgg, partialAgg)
     }
   }
 
-  private def pushPartialWrapperAggregate(lowerPartialAgg: Aggregate): 
LogicalPlan = {
-    // Push one join edge per iteration. This keeps the rewrite local and lets 
`maxDepth` bound
-    // how far a pushed aggregate is allowed to travel through a multi-join 
subtree.
+  private def pushPartialAggregate(lowerPartialAgg: Aggregate): 
Option[LogicalPlan] = {
+    // Push one join level per iteration; grouping-only aggregation can push 
to both sides.
     val partialMergeEnabled = 
GlutenConfig.get.pushAggregateThroughJoinPartialMergeEnabled
     var current: LogicalPlan = lowerPartialAgg
-    var changed = true
+    var candidates = Seq(lowerPartialAgg)
     var pushCount = 0
-    while (changed && pushCount < maxDepth) {
-      changed = false
+    var depth = 0
+    while (candidates.nonEmpty && depth < maxDepth) {
+      val nextCandidates = Seq.newBuilder[Aggregate]
       current = current.transformUp {
-        case partialAgg: Aggregate if 
isPurePartialWrapperAggregate(partialAgg) =>
+        // Only revisit aggregates created by the previous push, not unrelated 
aggregates.
+        case partialAgg: Aggregate if candidates.exists(_ eq partialAgg) =>
           // The physical strategy owns the top merge; retain logical merges 
only between joins.
           val retainPartialMerge = partialMergeEnabled && pushCount > 0
           val pushed = if (retainPartialMerge) {
             val (partialMergeAgg, freshLowerPartialAgg) = 
splitPartialAggregateForMerge(partialAgg)
             pushOnce(freshLowerPartialAgg).map {
-              pushedPlan => partialMergeAgg.copy(child = pushedPlan)
+              result =>
+                val mergeAgg = withPhase(
+                  partialMergeAgg.copy(child = result.plan),
+                  PartialMergePhase)
+                result.copy(plan = mergeAgg)
             }
           } else {
             pushOnce(partialAgg)
           }
           pushed match {
-            case Some(newPlan) =>
-              pushCount += 1
-              changed = true
-              newPlan
+            case Some(result) =>
+              pushCount += result.pushedAggregates.size
+              nextCandidates ++= result.pushedAggregates
+              result.plan
             case None =>
               partialAgg
           }
       }
+      candidates = nextCandidates.result()
+      depth += 1
     }
     successfulPushCount += pushCount
-    current
+    if (pushCount == 0) {
+      // Keep the original aggregate when no join push succeeds.
+      None
+    } else {
+      Some(current)
+    }
   }
 
-  private def pushOnce(partialAgg: Aggregate): Option[LogicalPlan] = {
+  private def pushOnce(partialAgg: Aggregate): Option[PushResult] = {
+    val hasBuffers = 
collectPartialWrapperAliases(partialAgg.aggregateExpressions).nonEmpty
+    if (!isDeterministicJoinPath(partialAgg.child)) {
+      // Skip pushdown: grouping rows before evaluating rand(), for example, 
changes the results.
+      return None
+    }
     extractJoin(partialAgg.child).flatMap {
       case (join, wrapperRequiredAttrs, rebuild) =>
-        Seq(JoinLeft, JoinRight).iterator
-          .flatMap {
-            side =>
-              val maybePushedJoin =
-                pushPartialAggToJoinSide(partialAgg, join, 
wrapperRequiredAttrs, side)
-              maybePushedJoin match {
-                case Some(pushedJoin) =>
-                  Some(rebuild(pushedJoin, partialAgg.output.collect { case a: 
Attribute => a }))
-                case None => None
-              }
+        val candidates: Seq[(JoinSide, Aggregate)] = Seq(JoinLeft, 
JoinRight).flatMap {
+          side =>
+            pushPartialAggToJoinSide(
+              partialAgg.groupingExpressions,
+              partialAgg.aggregateExpressions,
+              join,
+              wrapperRequiredAttrs,
+              side).map(agg => (side, agg))
+        }
+        val selected = if (!hasBuffers) {
+          // Without aggregate functions, DISTINCT can be pushed to both join 
sides simultaneously.
+          candidates
+        } else {
+          // When both sides qualify, choose one so the same aggregate buffer 
is not produced on both
+          // sides.
+          //
+          // SUM(fact.amount) qualifies only on the fact side because it 
requires fact.amount.
+          // COUNT(*) requires no columns, so both sides qualify; take(1) 
prefers the left side.
+          // TODO: Use statistics to choose a side when both qualify, rather 
than preferring the left.
+          candidates.take(1)
+        }
+        if (selected.isEmpty) {
+          None
+        } else {
+          val pushedJoin = selected.foldLeft(join) {
+            case (currentJoin, (side, pushedAgg)) => side.replace(currentJoin, 
pushedAgg)
           }
-          .toSeq
-          .headOption
+          Some(PushResult(
+            rebuild(pushedJoin, partialAgg.output.collect { case a: Attribute 
=> a }),
+            selected.map(_._2)))
+        }
     }
   }
 
-  private def pushPartialAggToJoinSide(
-      partialAgg: Aggregate,
+  // Public for testing.
+  def pushPartialAggToJoinSide(
+      groupingExpressions: Seq[Expression],
+      aggregateExpressions: Seq[NamedExpression],
       join: Join,
       wrapperRequiredAttrs: Seq[Attribute],
-      side: JoinSide): Option[Join] = {
-    // A pushed wrapper aggregate may move to a join side only when all of its 
aggregate inputs
-    // come from that side. The pushed grouping then has to include:
-    //   - grouping attrs already present on this side
-    //   - join keys / join-condition attrs required to preserve join semantics
-    //   - attrs referenced by non-pushable expressions still above the pushed 
aggregate
-    //   - attrs propagated from extracted Project / Filter nodes when those 
attrs are required to
-    //     preserve derived grouping expressions
-    //
-    // The pushed grouping must *not* include pure measure inputs of the 
pushed aggregate such as
-    // `ss_net_profit`, otherwise the pre-aggregation becomes over-constrained 
and ineffective.
-    val wrapperAliases = 
collectPartialWrapperAliases(partialAgg.aggregateExpressions)
-    if (wrapperAliases.isEmpty) {
+      side: JoinSide): Option[Aggregate] = {
+    val wrapperAliases = collectPartialWrapperAliases(aggregateExpressions)

Review Comment:
   This PR makes previously-private internals (`JoinSide`, 
`JoinLeft/JoinRight`, and `pushPartialAggToJoinSide`) part of the public 
surface area of `PushAggregateThroughJoin` (even if nested). If this is 
intended only for tests, consider reducing visibility (e.g., 
`private[joinagg]`) and placing the unit tests under 
`org.apache.gluten.extension.joinagg` so they can access package-private 
members, or annotate the method as test-only and keep the `JoinSide` helpers 
non-public to avoid expanding the supported API unintentionally.



##########
gluten-substrait/src/main/scala/org/apache/gluten/extension/joinagg/PushAggregateThroughJoin.scala:
##########
@@ -593,19 +619,19 @@ case class PushAggregateThroughJoin(spark: SparkSession)
     expr.collect { case attr: Attribute => attr }
   }
 
-  sealed private trait JoinSide {
+  sealed trait JoinSide {
     def plan(join: Join): LogicalPlan
     def outputSet(join: Join): AttributeSet
     def replace(join: Join, newPlan: LogicalPlan): Join
   }
 
-  private case object JoinLeft extends JoinSide {
+  case object JoinLeft extends JoinSide {
     override def plan(join: Join): LogicalPlan = join.left
     override def outputSet(join: Join): AttributeSet = join.left.outputSet
     override def replace(join: Join, newPlan: LogicalPlan): Join = 
join.copy(left = newPlan)
   }
 
-  private case object JoinRight extends JoinSide {
+  case object JoinRight extends JoinSide {
     override def plan(join: Join): LogicalPlan = join.right

Review Comment:
   This PR makes previously-private internals (`JoinSide`, 
`JoinLeft/JoinRight`, and `pushPartialAggToJoinSide`) part of the public 
surface area of `PushAggregateThroughJoin` (even if nested). If this is 
intended only for tests, consider reducing visibility (e.g., 
`private[joinagg]`) and placing the unit tests under 
`org.apache.gluten.extension.joinagg` so they can access package-private 
members, or annotate the method as test-only and keep the `JoinSide` helpers 
non-public to avoid expanding the supported API unintentionally.



##########
gluten-substrait/src/test/scala/org/apache/gluten/execution/PushAggregateThroughJoinRuleSuite.scala:
##########
@@ -0,0 +1,253 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.gluten.execution
+
+import org.apache.gluten.config.GlutenConfig
+import org.apache.gluten.extension.joinagg.{JoinAggregateFunctionWrapper, 
JoinAggregateTags, PushAggregateThroughJoin}
+import 
org.apache.gluten.extension.joinagg.JoinAggregateFunctionWrapper.{FinalPhase, 
PartialMergePhase, PartialPhase}
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, And, 
AttributeReference, EqualTo, GreaterThan, Literal, Rand}
+import org.apache.spark.sql.catalyst.expressions.aggregate.Sum
+import org.apache.spark.sql.catalyst.plans.{Inner, PlanTest}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, Join, 
JoinHint, LocalRelation, Project}
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.IntegerType
+
+class PushAggregateThroughJoinRuleSuite extends PlanTest with 
SharedSparkSession {
+  private val joinAggregateRule = PushAggregateThroughJoin(spark)
+
+  test("pushPartialAggToJoinSide accepts only an eligible side") {
+    val leftKey = AttributeReference("left_key", IntegerType)()
+    val value = AttributeReference("value", IntegerType)()
+    val rightKey = AttributeReference("right_key", IntegerType)()
+    val join = Join(
+      LocalRelation(Seq(leftKey, value)),
+      LocalRelation(Seq(rightKey)),
+      Inner,
+      Some(EqualTo(leftKey, rightKey)),
+      JoinHint.NONE)
+    val leftPartialSum = Alias(
+      
JoinAggregateFunctionWrapper.wrapperPartial(Sum(value)).toAggregateExpression(),
+      "left_partial_sum")()
+    val rightPartialSum = Alias(
+      
JoinAggregateFunctionWrapper.wrapperPartial(Sum(rightKey)).toAggregateExpression(),
+      "right_partial_sum")()
+
+    val leftPushed = joinAggregateRule.pushPartialAggToJoinSide(
+      Seq(leftKey),
+      Seq(leftKey, leftPartialSum),
+      join,
+      Nil,
+      joinAggregateRule.JoinLeft)
+    assert(leftPushed.exists(_.child eq join.left))
+    assert(leftPushed.get.aggregateExpressions.last == leftPartialSum)
+    
assert(leftPushed.get.getTagValue(JoinAggregateTags.phase).contains(PartialPhase))
+    assert(joinAggregateRule
+      .pushPartialAggToJoinSide(
+        Seq(leftKey),
+        Seq(leftKey, leftPartialSum),
+        join,
+        Nil,
+        joinAggregateRule.JoinRight)
+      .isEmpty)
+    val rightPushed = joinAggregateRule.pushPartialAggToJoinSide(
+      Seq(leftKey),
+      Seq(leftKey, rightPartialSum),
+      join,
+      Nil,
+      joinAggregateRule.JoinRight)
+    assert(rightPushed.exists(_.child eq join.right))
+  }
+
+  test("pushPartialAggToJoinSide supports grouping-only pushdown on either 
side") {
+    val leftKey = AttributeReference("left_key", IntegerType)()
+    val rightKey = AttributeReference("right_key", IntegerType)()
+    val join = Join(
+      LocalRelation(Seq(leftKey)),
+      LocalRelation(Seq(rightKey)),
+      Inner,
+      Some(EqualTo(leftKey, rightKey)),
+      JoinHint.NONE)
+    val keys = Seq(leftKey, rightKey)
+
+    Seq(joinAggregateRule.JoinLeft, joinAggregateRule.JoinRight).foreach {
+      side =>
+        val pushed = joinAggregateRule
+          .pushPartialAggToJoinSide(keys, keys, join, Nil, side)
+          .get
+        assert(pushed.child eq side.plan(join))
+        assert(pushed.groupingExpressions == side.plan(join).output)
+        assert(pushed.aggregateExpressions == side.plan(join).output)
+        
assert(pushed.getTagValue(JoinAggregateTags.phase).contains(PartialPhase))
+    }
+  }
+
+  test("grouping-only pushdown rule is idempotent") {
+    Seq(false, true).foreach {
+      partialMergeEnabled =>
+        withSQLConf(
+          GlutenConfig.PUSH_AGGREGATE_THROUGH_JOIN_ENABLED.key -> "true",
+          GlutenConfig.PUSH_AGGREGATE_THROUGH_JOIN_MAX_DEPTH.key -> "1",
+          GlutenConfig.PUSH_AGGREGATE_THROUGH_JOIN_PARTIAL_MERGE_ENABLED.key ->
+            partialMergeEnabled.toString
+        ) {
+          val leftKey = AttributeReference("left_key", IntegerType)()
+          val rightKey = AttributeReference("right_key", IntegerType)()
+          val join = Join(
+            LocalRelation(Seq(leftKey)),
+            LocalRelation(Seq(rightKey)),
+            Inner,
+            Some(EqualTo(leftKey, rightKey)),
+            JoinHint.NONE)
+          val keys = Seq(leftKey, rightKey)
+          val plan = Aggregate(
+            keys,
+            Seq(Alias(leftKey, "left_output")(), Alias(rightKey, 
"right_output")()),
+            Project(keys, join))
+          val rule = PushAggregateThroughJoin(spark)
+
+          val once = rule(plan)
+          assert(once.collect { case _: Aggregate => 1 }.size == 3)
+          assert(once.output == plan.output)
+          assert(once.resolved)
+          assert(once.collect { case p if p.missingInput.nonEmpty => p 
}.isEmpty)
+          assert(rule.getSuccessfulPushCount == 2)
+
+          val phases = once.collect {
+            case agg: Aggregate => agg.getTagValue(JoinAggregateTags.phase)
+          }
+          assert(phases == Seq(Some(FinalPhase), Some(PartialPhase), 
Some(PartialPhase)))

Review Comment:
   This assertion is brittle because it depends on the traversal order returned 
by `TreeNode.collect` (and implicitly on join child ordering). To avoid 
unnecessary test flakes during Spark/tree rewrite changes, assert the multiset 
of phases instead (e.g., counts per phase, or `phases.toSet` plus a size/count 
check), while still verifying that exactly one `FinalPhase` and two 
`PartialPhase` aggregates are present.



-- 
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