peter-toth commented on code in PR #58279:
URL: https://github.com/apache/spark/pull/58279#discussion_r3924540478


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -657,23 +660,30 @@ case class EnsureRequirements(
                 log"distribution.")
               replicateRightSide = false
             } else {
-              // In partially clustered distribution, we should use un-grouped 
partition values
-              val (partiallyClusteredChild, partiallyClusteredSpec) = if 
(replicateLeftSide) {
-                (unwrappedRight, rightSpec)
-              } else {
-                (unwrappedLeft, leftSpec)
-              }
-              // Original `KeyedPartitioning` can be obtained from the child 
directly if the child
-              // satisfied the distribution requirement; or from the child's 
child if it didn't as
-              // the child must be a `GroupPartitionsExec` inserted by 
`EnsureRequirement`
-              // to satisfy the distribution requirement.
+              // In partially clustered distribution, we should use un-grouped 
partition values.
+              // The positions projecting them come from the innermost 
grouping when there is
+              // one: like in `applyGroupPartitions`, they were computed 
against the raw
+              // partition keys, while the spec's were computed against the 
node's already
+              // projected report on a re-run.
+              val (partiallyClusteredChild, partiallyClusteredPositions) =

Review Comment:
   **Finding 9.** `unwrapGroupPartitions(right)` at `:618` and 
`innermostGroupPartition(right)` here run the same descent and land on the same 
node. That is what makes the pair sound. The keys come from that node's child, 
the positions from that node itself. The code derives each one separately and 
pairs them by hand, so the pairing holds because two call sites agree rather 
than because it is one value.
   
   That is the shape findings 3 and 4 came out of. The plan moved to the 
pre-alignment node, the positions stayed on the spec, and nothing failed to 
compile. One value removes the class. At `:617-618`:
   
   ```scala
               val leftGrouping = innermostGroupPartition(left)
               val rightGrouping = innermostGroupPartition(right)
               val unwrappedLeft = leftGrouping.map(_._1.child).getOrElse(left)
               val unwrappedRight = 
rightGrouping.map(_._1.child).getOrElse(right)
   ```
   
   and here:
   
   ```scala
                 val (partiallyClusteredChild, partiallyClusteredPositions) =
                   if (replicateLeftSide) {
                     (unwrappedRight,
                       
rightGrouping.flatMap(_._1.joinKeyPositions).orElse(rightSpec.joinKeyPositions))
                   } else {
                     (unwrappedLeft,
                       
leftGrouping.flatMap(_._1.joinKeyPositions).orElse(leftSpec.joinKeyPositions))
                   }
   ```
   
   The descent then runs twice per join instead of three times. 
`unwrapGroupPartitions` is left with its one remaining caller, the 
`ShuffleExchangeExec` site at `:288`.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -657,23 +660,30 @@ case class EnsureRequirements(
                 log"distribution.")
               replicateRightSide = false
             } else {
-              // In partially clustered distribution, we should use un-grouped 
partition values
-              val (partiallyClusteredChild, partiallyClusteredSpec) = if 
(replicateLeftSide) {
-                (unwrappedRight, rightSpec)
-              } else {
-                (unwrappedLeft, leftSpec)
-              }
-              // Original `KeyedPartitioning` can be obtained from the child 
directly if the child
-              // satisfied the distribution requirement; or from the child's 
child if it didn't as
-              // the child must be a `GroupPartitionsExec` inserted by 
`EnsureRequirement`
-              // to satisfy the distribution requirement.
+              // In partially clustered distribution, we should use un-grouped 
partition values.

Review Comment:
   **Finding 10.** This is about `:644`, which sits just above the hunk, so 
this is the nearest line I can anchor on.
   
   The statistics read at `:623-645` now comes from the pre-alignment plan. The 
branch it falls back to does not. `leftPartKeys` and `rightPartKeys` at 
`:551-552` are `leftSpec`/`rightSpec`'s keys, and those specs are built at 
`:167` from the aligned node's report. Both sides align to the same 
`mergedPartitionKeys` with the same per-key counts, so on a re-run the two 
lists have equal length and `leftPartKeys.size < rightPartKeys.size` is `false` 
whatever pass 1 chose. That is the flip 
[r3864640486](https://github.com/apache/spark/pull/58279#discussion_r3864640486)
 describes. The fix reached the trigger and left the flip.
   
   I could not reach it on this head and the argument here is from reading. 
After the fix `unwrapGroupPartitions` lands on the join child pass 1 planned, 
which carries a `logicalLink`, so the statistics branch wins unless a side 
reports `sizeInBytes <= 1`. I instrumented this fallback and ran 
`KeyGroupedPartitioningSuite` plus `EnsureRequirementsSuite`. It did not fire 
once in 187 tests.
   
   So this is about holding the invariant at both branches, not about a defect 
today. The pre-alignment counts are one `collectFirst` away, and `:684` already 
does that extraction for one side:
   
   ```scala
                 // The pre-alignment split counts, for the same reason the 
statistics above read the
                 // pre-alignment plan. On a re-run both aligned reports hold 
the same number of keys.
                 def rawNumKeys(plan: SparkPlan, aligned: Int): Int =
                   plan.outputPartitioning match {
                     case e: Expression => e
                       .collectFirst { case k: KeyedPartitioning => 
k.numPartitions }
                       .getOrElse(aligned)
                     case _ => aligned
                   }
                 rawNumKeys(unwrappedLeft, leftPartKeys.size) <
                   rawNumKeys(unwrappedRight, rightPartKeys.size)
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -731,38 +741,97 @@ case class EnsureRequirements(
   }
 
   /**
-   * Unwraps a GroupPartitionsExec to get the underlying child plan.
+   * The innermost `GroupPartitionsExec` reachable from `plan` by descending 
only through nodes
+   * this rule itself inserted above it, together with a function rebuilding 
the traversed local
+   * sorts over a replacement node. `None` when no `GroupPartitionsExec` is 
reachable.
+   *
+   * The descent only traverses a `GroupPartitionsExec` and a *local* 
`SortExec`. That bound is a
+   * decision, not an omission: a `GroupPartitionsExec` hidden behind any 
other node belongs to a
+   * different operator, and reusing it would move that operator's alignment. 
Instrumenting the
+   * descent over `KeyGroupedPartitioningSuite`, the non-`SortExec` shapes 
hiding a node are
+   * `Project > SortMergeJoin > Sort > GroupPartitions` and `Project > Filter 
> Window >
+   * WindowGroupLimit > GroupPartitions`, where refusing to descend is right 
every time. A global
+   * `SortExec` also stops the descent: it requires `OrderedDistribution`, 
which a
+   * `KeyedPartitioning` can satisfy (behind 
`spark.sql.sources.v2.bucketing.sorting.enabled`)
+   * through a `GroupPartitionsExec` built to emit the partition keys in 
sorted order, and
+   * reusing that node for a join would destroy the ordering it exists to 
provide.
    */
-  private def unwrapGroupPartitions(plan: SparkPlan): SparkPlan = plan match {
-    case g: GroupPartitionsExec => g.child
-    case other => other
+  private def innermostGroupPartition(
+      plan: SparkPlan): Option[(GroupPartitionsExec, SparkPlan => SparkPlan)] 
= plan match {
+    case g: GroupPartitionsExec =>
+      // A grouping over another grouping is one this rule added in an earlier 
pass: keep the
+      // descent below it and drop this one.
+      innermostGroupPartition(g.child).orElse(Some((g, identity[SparkPlan])))
+    case s: SortExec if !s.global =>
+      innermostGroupPartition(s.child).map { case (g, rebuild) =>
+        (g, (newChild: SparkPlan) => s.withNewChildren(Seq(rebuild(newChild))))
+      }
+    case _ => None
   }
 
+  /**
+   * Rewrites the innermost `GroupPartitionsExec` in `plan` with `f` and drops 
any redundant
+   * grouping stacked above it, per the descent of 
[[innermostGroupPartition]]. Returns `None`
+   * when `plan` holds no `GroupPartitionsExec`, leaving it to the caller to 
create one.
+   *
+   * This is what makes the rule idempotent for storage-partitioned joins. 
`EnsureRequirements`
+   * is re-run on plans it already produced: `AdaptiveSparkPlanExec` builds 
one instance of this
+   * rule, and `ConvertSortMergeJoinToShuffledHashJoin` and 
`OptimizeSkewedJoin` hand the whole
+   * tree back to it after rewriting some other join, all within one
+   * `queryStagePreparationRules` pass. A join child then arrives as
+   * `SortExec(GroupPartitionsExec(...))` rather than a bare scan, and the 
distribution step adds
+   * a plain `GroupPartitionsExec` on top, because a partially clustered 
`KeyedPartitioning`
+   * reports `isGrouped = false` by design and so is only satisfied "after 
grouping". Rewriting
+   * that outer node instead of the one below it re-derives the alignment from 
an already-aligned
+   * layout and duplicates rows; descending to the innermost node and dropping 
what sits above it
+   * reproduces the plan a single pass would have produced.
+   *
+   * Dropping a grouping is safe because only `applyGroupPartitions` calls 
this, reached from
+   * `checkKeyGroupCompatible`, which runs for joins alone: every 
`GroupPartitionsExec` a join
+   * child carries is this rule's own. A single-child operator genuinely needs 
its non-grouped
+   * input grouped and takes the wrap in the children loop instead; 
`withJoinKeyPositions`, which
+   * other multi-child operators reach, does not reuse at depth.
+   */
+  private[exchange] def rewriteGroupPartitions(plan: SparkPlan)(
+      f: GroupPartitionsExec => GroupPartitionsExec): Option[SparkPlan] =
+    innermostGroupPartition(plan).map { case (g, rebuild) =>
+      val rewritten = f(g)
+      rewritten.copyTagsFrom(g)
+      rebuild(rewritten)
+    }
+
+  /**
+   * Unwraps the `GroupPartitionsExec` nodes this rule inserted over a join 
child, down to the
+   * pre-alignment plan, per the descent of [[innermostGroupPartition]].
+   *
+   * The statistics-based replicate-side choice and the original partition 
keys below must read
+   * from the pre-alignment plan on every pass: the local sort one level down 
carries no
+   * `logicalLink` and reports the aligned layout instead.
+   */
+  private def unwrapGroupPartitions(plan: SparkPlan): SparkPlan =
+    innermostGroupPartition(plan).map(_._1.child).getOrElse(plan)
+
   /**
    * Applies or updates `GroupPartitionsExec` with the given parameters.
    *
-   * `GroupPartitionsExec` can be either the given plan node (child of the 
join inserted by
-   * `EnsureRequirement`) if the original child didn't satisfy the 
distribution requirement; or we
-   * can create a new one specifically for this join.
+   * Reuses the node this rule inserted over the join child in an earlier 
pass, per the descent
+   * of [[innermostGroupPartition]], and creates a new one when the child 
carries none.
    */
   private def applyGroupPartitions(
       plan: SparkPlan,
       joinKeyPositions: Option[Seq[Int]],
       mergedPartitionKeys: Seq[(InternalRowComparableWrapper, Int)],
       reducers: Option[Seq[Option[KeyReducer]]],
       distributePartitions: Boolean): SparkPlan = {
-    plan match {
-      case g: GroupPartitionsExec =>
-        val newGroupPartitions = g.copy(
-          joinKeyPositions = joinKeyPositions,
-          expectedPartitionKeys = Some(mergedPartitionKeys),
-          reducers = reducers,
-          distributePartitions = distributePartitions)
-        newGroupPartitions.copyTagsFrom(g)
-        newGroupPartitions
-      case _ =>
-        GroupPartitionsExec(plan, joinKeyPositions, Some(mergedPartitionKeys), 
reducers,
-          distributePartitions)
+    rewriteGroupPartitions(plan) { g =>
+      g.copy(
+        joinKeyPositions = g.joinKeyPositions.orElse(joinKeyPositions),
+        expectedPartitionKeys = Some(mergedPartitionKeys),
+        reducers = reducers,

Review Comment:
   **Finding 11.** `joinKeyPositions` one line up keeps what the reused node 
holds, because the incoming value was computed against the node's already 
projected report. `reducers` is computed the same way. 
`leftSpec.reducersBothWays(rightSpec)` at `:564` runs over the reported 
expressions, which on a re-run are the reduced ones this node produced. It is 
written straight through.
   
   Nothing goes wrong on this head, and I traced why. A re-run with reducers 
never reaches here. Both sides report the same reduced keys and the same 
expressions after pass 1, so `isCompatible` at `:525-528` is true. 
`v2BucketingPartiallyClusteredDistributionEnabled` is false, because a reducer 
needs `canReduceKeys` and that excludes it. So the whole `if` at `:529` is 
skipped.
   
   I measured it rather than only tracing it. Printing `g.reducers` and the 
incoming value here and running `KeyGroupedPartitioningSuite` plus 
`EnsureRequirementsSuite`, it fires 86 times, and every one is `existing=false, 
incoming=true, expectedPartitionKeys set=false`. The node is always the one the 
children loop just created, never an aligned one from an earlier pass.
   
   So this is a request for the reason in the source, not a fix. Next to a 
defended `orElse` the plain assignment reads like an oversight. And if that 
short-circuit ever weakens, the reused node silently loses its reducer, its 
keys go back to the raw space, and every expected key misses.
   
   ```suggestion
           // Unlike `joinKeyPositions`, these need no `orElse`. A re-run with 
reducers never reaches
           // here. Both sides then report the same reduced keys, so 
`isCompatible` above is true and
           // the whole block is skipped.
           reducers = reducers,
   ```
   



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