sunchao commented on code in PR #58771:
URL: https://github.com/apache/spark/pull/58771#discussion_r4011344415


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -584,217 +582,322 @@ case class EnsureRequirements(
     // return `None` after logging a pushdown that does not happen.
     if (bestPair.isEmpty) return None
     val (leftSpec, rightSpec) = bestPair.get
-    val leftPartitioning = leftSpec.partitioning
-    val rightPartitioning = rightSpec.partitioning
 
     // We don't need to alter the existing or add new `GroupPartitionsExec` 
when the child
     // partitionings are not modified (projected) in specs and left and right 
side partitionings are
     // compatible with each other.
     val compatibleAsIs =
       bothUnprojected(leftSpec, rightSpec) && 
leftSpec.isCompatibleWith(rightSpec)
-    // Entering the push branch is the same as taking it. The keys agree for 
this pair by
-    // construction, since `agreeingPairs` filtered on exactly that and an 
empty result returned
-    // above, so what the branch pushes always applies.
-    val pushCommonValues =
-      (!compatibleAsIs || 
conf.v2BucketingPartiallyClusteredDistributionEnabled) &&
+    // The skew term keeps the branch on wherever a split could apply: over 
pairs the children
+    // loop already made compatible by coalescing duplicate keys, and over 
re-runs of the
+    // split-aligned plans the branch produced. A pair the branch finds 
nothing to reserve goes
+    // back untouched, see `alignedJoinChildren`.
+    val alignKeyGroups =
+      (!compatibleAsIs || 
conf.v2BucketingPartiallyClusteredDistributionEnabled ||
+        conf.v2BucketingSkewJoinEnabled) &&
         (conf.v2BucketingPushPartValuesEnabled ||
           conf.v2BucketingAllowKeysSubsetOfPartitionKeys)
-    if (pushCommonValues) {
-      logInfo("Pushing common partition values for storage-partitioned join")
-
-      // Partition expressions are compatible. Regardless of whether partition 
values
-      // match from both sides of children, we can calculate a superset of 
partition values and
-      // push-down to respective data sources so they can adjust their output 
partitioning by
-      // filling missing partition keys with empty partitions. As result, we 
can still avoid
-      // shuffle.
-      //
-      // For instance, if two sides of a join have partition expressions
-      // `day(a)` and `day(b)` respectively
-      // (the join query could be `SELECT ... FROM t1 JOIN t2 on t1.a = 
t2.b`), but
-      // with different partition values:
-      //   `day(a)`: [0, 1]
-      //   `day(b)`: [1, 2, 3]
-      // Following the case 2 above, we don't have to shuffle both sides, but 
instead can
-      // just push the common set of partition values: `[0, 1, 2, 3]` down to 
the two data
-      // sources.
-      val leftPartKeys = leftPartitioning.partitionKeys
-      val rightPartKeys = rightPartitioning.partitionKeys
-
-      val numLeftPartKeys = MDC(LogKeys.NUM_LEFT_PARTITION_VALUES, 
leftPartKeys.size)
-      val numRightPartKeys = MDC(LogKeys.NUM_RIGHT_PARTITION_VALUES, 
rightPartKeys.size)
-      logInfo(
-        log"""
-            |Left side # of partitions: $numLeftPartKeys
-            |Right side # of partitions: $numRightPartKeys
-            |""".stripMargin)
-
-      // in case of compatible but not identical partition expressions, we 
apply 'reduce'
-      // transforms to group one side's partitions as well as the common 
partition values
-      val (leftReducers, rightReducers) = leftSpec.reducersBothWays(rightSpec)
-      val (leftReducedDataTypes, leftReducedKeys) = leftReducers.fold(
-        (leftPartitioning.keyDataTypes, leftPartitioning.partitionKeys)
-      )(leftPartitioning.reduceKeys)
-      val (rightReducedDataTypes, rightReducedKeys) = rightReducers.fold(
-        (rightPartitioning.keyDataTypes, rightPartitioning.partitionKeys)
-      )(rightPartitioning.reduceKeys)
-      // The reduced types are the types of the key rows the merge below sees, 
and a side with no
-      // key row left answers for them from its layout, which a reduce writes 
them into. So the
-      // comparison is meaningful on both sides whether or not either has a 
key (SPARK-59176).
-      if (leftReducedDataTypes != rightReducedDataTypes) {
-        // The two lists are the erased ones, so a struct in the message 
prints positional field
-        // names. That is deliberate: the names do not decide where a key 
belongs, so printing the
-        // connector's own would point a reader at a difference that is not 
the cause.
-        throw 
QueryExecutionErrors.storagePartitionJoinIncompatibleReducedTypesError(
-          leftReducers = leftReducers,
-          leftReducedDataTypes = leftReducedDataTypes,
-          rightReducers = rightReducers,
-          rightReducedDataTypes = rightReducedDataTypes)
-      }
+    if (alignKeyGroups) {
+      Some(alignedJoinChildren(left, right, leftSpec, rightSpec, joinType, 
compatibleAsIs))
+    } else if (compatibleAsIs) {
+      Some(Seq(left, right))
+    } else {
+      None
+    }
+  }
 
-      val reducedKeyOrdering = 
KeyedPartitioning.groupedKeyRowOrdering(leftReducedDataTypes)
-        .on((t: InternalRowComparableWrapper) => t.row)
-
-      // merge values on both sides
-      var mergedPartitionKeys =
-        mergeAndDedupPartitions(leftReducedKeys, rightReducedKeys, joinType, 
reducedKeyOrdering)
-          .map((_, 1))
-
-      logInfo(log"After merging, there are " +
-        log"${MDC(LogKeys.NUM_PARTITIONS, mergedPartitionKeys.size)} 
partitions")
-
-      var replicateLeftSide = false
-      var replicateRightSide = false
-      var applyPartialClustering = false
-
-      // This means we allow partitions that are not clustered on their values,
-      // that is, multiple partitions with the same partition value. In the
-      // following, we calculate how many partitions that each distinct 
partition
-      // value has, and pushdown the information to scans, so they can adjust 
their
-      // final input partitions respectively.
-      if (conf.v2BucketingPartiallyClusteredDistributionEnabled) {
-        logInfo("Calculating partially clustered distribution for " +
-            "storage-partitioned join")
-
-        // Similar to `OptimizeSkewedJoin`, we need to check join type and 
decide
-        // whether partially clustered distribution can be applied. For 
instance, the
-        // optimization cannot be applied to a left outer join, where the left 
hand
-        // side is chosen as the side to replicate partitions according to 
stats.
-        // Otherwise, query result could be incorrect.
-        val canReplicateLeft = ShuffledJoin.canDuplicateLeftSide(joinType)
-        val canReplicateRight = ShuffledJoin.canDuplicateRightSide(joinType)
-
-        if (!canReplicateLeft && !canReplicateRight) {
-          logInfo(log"Skipping partially clustered distribution as it cannot 
be applied for " +
-            log"join type '${MDC(LogKeys.JOIN_TYPE, joinType)}'")
+  /**
+   * Aligns both sides of a storage-partitioned join on one shared, merged and 
deduped set of
+   * partition keys, through the [[GroupPartitionsExec]]s over the two 
children. The per-key
+   * layout is one of three, in increasing precedence:
+   *
+   * 1. Coalescing, the default: every key reserves one output partition per 
side.
+   * 2. Partial clustering (`partiallyClusteredDistribution.enabled`): one 
side keeps its splits
+   *    spread for every key while the other replicates its group to match.
+   * 3. Per-key skew split (`skewJoin.enabled`): a key over the skew threshold 
spreads the side
+   *    holding more of its splits and replicates the other, gated per key on 
what the join type
+   *    may replicate. Overrides the partial clustering on that key.
+   *
+   * When the sides' partition transforms are compatible but not identical, 
reducers bring one
+   * side's keys into the other's space before the merge.
+   */
+  private def alignedJoinChildren(
+      left: SparkPlan,
+      right: SparkPlan,
+      leftSpec: KeyedShuffleSpec,
+      rightSpec: KeyedShuffleSpec,
+      joinType: JoinType,
+      compatibleAsIs: Boolean): Seq[SparkPlan] = {
+    val leftPartitioning = leftSpec.partitioning
+    val rightPartitioning = rightSpec.partitioning
+
+    logInfo("Aligning join children on merged partition values for 
storage-partitioned join")
+
+    // Partition expressions are compatible. Regardless of whether partition 
values
+    // match from both sides of children, we can calculate a superset of 
partition values and
+    // push-down to respective data sources so they can adjust their output 
partitioning by
+    // filling missing partition keys with empty partitions. As result, we can 
still avoid
+    // shuffle.
+    //
+    // For instance, if two sides of a join have partition expressions
+    // `day(a)` and `day(b)` respectively
+    // (the join query could be `SELECT ... FROM t1 JOIN t2 on t1.a = t2.b`), 
but
+    // with different partition values:
+    //   `day(a)`: [0, 1]
+    //   `day(b)`: [1, 2, 3]
+    // Following the case 2 above, we don't have to shuffle both sides, but 
instead can
+    // just push the common set of partition values: `[0, 1, 2, 3]` down to 
the two data
+    // sources.
+    val leftPartKeys = leftPartitioning.partitionKeys
+    val rightPartKeys = rightPartitioning.partitionKeys
+
+    val numLeftPartKeys = MDC(LogKeys.NUM_LEFT_PARTITION_VALUES, 
leftPartKeys.size)
+    val numRightPartKeys = MDC(LogKeys.NUM_RIGHT_PARTITION_VALUES, 
rightPartKeys.size)
+    logInfo(
+      log"""
+          |Left side # of partitions: $numLeftPartKeys
+          |Right side # of partitions: $numRightPartKeys
+          |""".stripMargin)
+
+    // in case of compatible but not identical partition expressions, we apply 
'reduce'
+    // transforms to group one side's partitions as well as the common 
partition values
+    val (leftReducers, rightReducers) = leftSpec.reducersBothWays(rightSpec)
+    val (leftReducedDataTypes, leftReducedKeys) = leftReducers.fold(
+      (leftPartitioning.keyDataTypes, leftPartitioning.partitionKeys)
+    )(leftPartitioning.reduceKeys)
+    val (rightReducedDataTypes, rightReducedKeys) = rightReducers.fold(
+      (rightPartitioning.keyDataTypes, rightPartitioning.partitionKeys)
+    )(rightPartitioning.reduceKeys)
+    // The reduced types are the types of the key rows the merge below sees, 
and a side with no
+    // key row left answers for them from its layout, which a reduce writes 
them into. So the
+    // comparison is meaningful on both sides whether or not either has a key 
(SPARK-59176).
+    if (leftReducedDataTypes != rightReducedDataTypes) {
+      // The two lists are the erased ones, so a struct in the message prints 
positional field
+      // names. That is deliberate: the names do not decide where a key 
belongs, so printing the
+      // connector's own would point a reader at a difference that is not the 
cause.
+      throw 
QueryExecutionErrors.storagePartitionJoinIncompatibleReducedTypesError(
+        leftReducers = leftReducers,
+        leftReducedDataTypes = leftReducedDataTypes,
+        rightReducers = rightReducers,
+        rightReducedDataTypes = rightReducedDataTypes)
+    }
+
+    val reducedKeyOrdering = 
KeyedPartitioning.groupedKeyRowOrdering(leftReducedDataTypes)
+      .on((t: InternalRowComparableWrapper) => t.row)
+
+    // merge values on both sides
+    var mergedPartitionKeys =
+      mergeAndDedupPartitions(leftReducedKeys, rightReducedKeys, joinType, 
reducedKeyOrdering)
+        .map((_, 1))
+
+    logInfo(log"After merging, there are " +
+      log"${MDC(LogKeys.NUM_PARTITIONS, mergedPartitionKeys.size)} partitions")
+
+    var replicateLeftSide = false
+    var replicateRightSide = false
+    var applyPartialClustering = false
+
+    // The pre-alignment plan of each side and the grouping over it, read 
lazily by the
+    // partially clustered and skew-split decisions below: both need the split 
counts and side
+    // statistics a re-run has to reproduce, which the aligned reports no 
longer hold.
+    lazy val leftGrouping = innermostGroupPartition(left)
+    lazy val rightGrouping = innermostGroupPartition(right)
+    lazy val unwrappedLeft = leftGrouping.map(_._1.child).getOrElse(left)
+    lazy val unwrappedRight = rightGrouping.map(_._1.child).getOrElse(right)
+
+    // Per-key input partition splits of a side. The keys come from the node's 
child and the
+    // positions from the node itself, falling back to the spec's when there 
is no grouping: the
+    // node's positions index the raw partition keys, the spec's the projected 
report. The
+    // partitioning holds a `KeyedPartitioning`, else this side would have had 
no spec at all.
+    def splitsPerKey(
+        grouping: Option[(GroupPartitionsExec, SparkPlan => SparkPlan)],
+        unwrapped: SparkPlan,
+        spec: KeyedShuffleSpec): Map[InternalRowComparableWrapper, Int] = {
+      val keyed = unwrapped.outputPartitioning.asInstanceOf[Expression]
+        .collectFirst { case k: KeyedPartitioning => k }.get
+      val positions = 
grouping.flatMap(_._1.joinKeyPositions).orElse(spec.joinKeyPositions)
+      positions.fold(keyed.partitionKeys)(keyed.projectKeys(_)._2)
+        .groupBy(identity).view.mapValues(_.size).toMap
+    }
+    lazy val leftSplitsPerKey = splitsPerKey(leftGrouping, unwrappedLeft, 
leftSpec)
+    lazy val rightSplitsPerKey = splitsPerKey(rightGrouping, unwrappedRight, 
rightSpec)
+
+    // Whether replicating a side over the other's splits preserves the 
result. Partial
+    // clustering needs any side to be replicable; the skew split is gated per 
key, since a join
+    // type admitting one side still forbids the other (a left outer join 
replicates the right
+    // side only).
+    val canReplicateLeft = ShuffledJoin.canDuplicateLeftSide(joinType)
+    val canReplicateRight = ShuffledJoin.canDuplicateRightSide(joinType)
+
+    // This means we allow partitions that are not clustered on their values,
+    // that is, multiple partitions with the same partition value. In the
+    // following, we calculate how many partitions that each distinct partition
+    // value has, and pushdown the information to scans, so they can adjust 
their
+    // final input partitions respectively.
+    if (conf.v2BucketingPartiallyClusteredDistributionEnabled) {
+      logInfo("Calculating partially clustered distribution for " +
+          "storage-partitioned join")
+
+      // Similar to `OptimizeSkewedJoin`, we need to check join type and decide
+      // whether partially clustered distribution can be applied. For 
instance, the
+      // optimization cannot be applied to a left outer join, where the left 
hand
+      // side is chosen as the side to replicate partitions according to stats.
+      // Otherwise, query result could be incorrect.
+      if (!canReplicateLeft && !canReplicateRight) {
+        logInfo(log"Skipping partially clustered distribution as it cannot be 
applied for " +
+          log"join type '${MDC(LogKeys.JOIN_TYPE, joinType)}'")
+      } else {
+        val leftLink = unwrappedLeft.logicalLink
+        val rightLink = unwrappedRight.logicalLink
+
+        replicateLeftSide = if (
+          leftLink.isDefined && rightLink.isDefined &&
+              leftLink.get.stats.sizeInBytes > 1 &&
+              rightLink.get.stats.sizeInBytes > 1) {
+          val leftLinkStatsSizeInBytes = 
MDC(LogKeys.LEFT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
+            leftLink.get.stats.sizeInBytes)
+          val rightLinkStatsSizeInBytes = 
MDC(LogKeys.RIGHT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
+            rightLink.get.stats.sizeInBytes)
+          logInfo(
+            log"""
+               |Using plan statistics to determine which side of join to fully
+               |cluster partition values:
+               |Left side size (in bytes): $leftLinkStatsSizeInBytes
+               |Right side size (in bytes): $rightLinkStatsSizeInBytes
+               |""".stripMargin)
+          leftLink.get.stats.sizeInBytes < rightLink.get.stats.sizeInBytes
         } else {
-          // The pre-alignment plan of each side and the grouping this rule 
inserted over it,
-          // are read once: the statistics and the original partition keys 
below come from the
-          // plan, the positions projecting them from the grouping.
-          val leftGrouping = innermostGroupPartition(left)
-          val rightGrouping = innermostGroupPartition(right)
-          val unwrappedLeft = leftGrouping.map(_._1.child).getOrElse(left)
-          val unwrappedRight = rightGrouping.map(_._1.child).getOrElse(right)
-
-          val leftLink = unwrappedLeft.logicalLink
-          val rightLink = unwrappedRight.logicalLink
-
-          replicateLeftSide = if (
-            leftLink.isDefined && rightLink.isDefined &&
-                leftLink.get.stats.sizeInBytes > 1 &&
-                rightLink.get.stats.sizeInBytes > 1) {
-            val leftLinkStatsSizeInBytes = 
MDC(LogKeys.LEFT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
-              leftLink.get.stats.sizeInBytes)
-            val rightLinkStatsSizeInBytes = 
MDC(LogKeys.RIGHT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
-              rightLink.get.stats.sizeInBytes)
-            logInfo(
-              log"""
-                 |Using plan statistics to determine which side of join to 
fully
-                 |cluster partition values:
-                 |Left side size (in bytes): $leftLinkStatsSizeInBytes
-                 |Right side size (in bytes): $rightLinkStatsSizeInBytes
-                 |""".stripMargin)
-            leftLink.get.stats.sizeInBytes < rightLink.get.stats.sizeInBytes
-          } else {
-            // As a simple heuristic, we pick the side with fewer partitions 
to apply the
-            // grouping & replication of partitions. The counts read the
-            // pre-alignment plans, for the same reason the statistics do: on 
a re-run both
-            // aligned reports hold the same number of keys, so comparing them 
decides nothing.
-            // This also changes a first pass, which compared the aligned 
report's distinct
-            // keys rather than the splits behind them.
-            logInfo("Using number of partitions to determine which side of 
join " +
-                "to fully cluster partition values")
-            
PartitioningCollection.numKeyedPartitions(unwrappedLeft.outputPartitioning)
-              .getOrElse(leftPartKeys.size) <
-              
PartitioningCollection.numKeyedPartitions(unwrappedRight.outputPartitioning)
-              .getOrElse(rightPartKeys.size)
+          // As a simple heuristic, we pick the side with fewer partitions to 
apply the
+          // grouping & replication of partitions. The counts read the
+          // pre-alignment plans, for the same reason the statistics do: on a 
re-run both
+          // aligned reports hold the same number of keys, so comparing them 
decides nothing.
+          // This also changes a first pass, which compared the aligned 
report's distinct
+          // keys rather than the splits behind them.
+          logInfo("Using number of partitions to determine which side of join 
" +
+              "to fully cluster partition values")
+          
PartitioningCollection.numKeyedPartitions(unwrappedLeft.outputPartitioning)
+            .getOrElse(leftPartKeys.size) <
+            
PartitioningCollection.numKeyedPartitions(unwrappedRight.outputPartitioning)
+            .getOrElse(rightPartKeys.size)
+        }
+
+        replicateRightSide = !replicateLeftSide
+
+        // Similar to skewed join, we need to check the join type to see 
whether replication
+        // of partitions can be applied. For instance, replication should not 
be allowed for
+        // the left-hand side of a left outer join.
+        if (replicateLeftSide && !canReplicateLeft) {
+          logInfo(log"Left-hand side is picked but cannot be applied to join 
type " +
+            log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
+            log"distribution.")
+          replicateLeftSide = false
+        } else if (replicateRightSide && !canReplicateRight) {
+          logInfo(log"Right-hand side is picked but cannot be applied to join 
type " +
+            log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
+            log"distribution.")
+          replicateRightSide = false
+        } else {
+          // In partially clustered distribution, we should use un-grouped 
partition values:
+          // the side that keeps its splits sizes every key from its own 
pre-alignment counts.
+          val numExpectedPartitions =
+            if (replicateLeftSide) rightSplitsPerKey else leftSplitsPerKey
+
+          mergedPartitionKeys = mergedPartitionKeys.map { case (key, numParts) 
=>
+            (key, numExpectedPartitions.getOrElse(key, numParts))
           }
 
-          replicateRightSide = !replicateLeftSide
-
-          // Similar to skewed join, we need to check the join type to see 
whether replication
-          // of partitions can be applied. For instance, replication should 
not be allowed for
-          // the left-hand side of a left outer join.
-          if (replicateLeftSide && !canReplicateLeft) {
-            logInfo(log"Left-hand side is picked but cannot be applied to join 
type " +
-              log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
-              log"distribution.")
-            replicateLeftSide = false
-          } else if (replicateRightSide && !canReplicateRight) {
-            logInfo(log"Right-hand side is picked but cannot be applied to 
join type " +
-              log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
-              log"distribution.")
-            replicateRightSide = false
-          } else {
-            // In partially clustered distribution, we should use un-grouped 
partition values.
-            // The child and the positions projecting its keys come from the 
same grouping:
-            // the keys from the node's child, the positions from the node 
itself, falling back
-            // to the spec's when there is no grouping. Like in 
`applyGroupPartitions`, the
-            // node's positions 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) =
-              if (replicateLeftSide) {
-                (unwrappedRight,
-                  rightGrouping.flatMap(_._1.joinKeyPositions)
-                    .orElse(rightSpec.joinKeyPositions))
-              } else {
-                (unwrappedLeft,
-                  leftGrouping.flatMap(_._1.joinKeyPositions)
-                    .orElse(leftSpec.joinKeyPositions))
-              }
-            // The pre-alignment plan of the side that keeps its splits: its 
partitioning
-            // still holds the original partition keys, one per input split.
-            val originalPartitioning =
-              
partiallyClusteredChild.outputPartitioning.asInstanceOf[Expression]
-            // `outputPartitioning` is either a `PartitioningCollection` or a 
`KeyedPartitioning`
-            // otherwise `createKeyedShuffleSpecs()` would have returned 
nothing.
-            val originalKeyedPartitioning =
-              originalPartitioning.collectFirst { case k: KeyedPartitioning => 
k }.get
-            val projectedOriginalPartitionKeys = partiallyClusteredPositions
-              .fold(originalKeyedPartitioning.partitionKeys)(
-                originalKeyedPartitioning.projectKeys(_)._2)
-
-            val numExpectedPartitions =
-              
projectedOriginalPartitionKeys.groupBy(identity).view.mapValues(_.size)
-
-            mergedPartitionKeys = mergedPartitionKeys.map { case (key, 
numParts) =>
-              (key, numExpectedPartitions.getOrElse(key, numParts))
-            }
+          logInfo(log"After applying partially clustered distribution, there 
are " +
+            log"${MDC(LogKeys.NUM_PARTITIONS, 
mergedPartitionKeys.map(_._2).sum)} partitions.")
+          applyPartialClustering = true
+        }
+      }
+    }
 
-            logInfo(log"After applying partially clustered distribution, there 
are " +
-              log"${MDC(LogKeys.NUM_PARTITIONS, 
mergedPartitionKeys.map(_._2).sum)} partitions.")
-            applyPartialClustering = true
+    // Per-key skew split: the default alignment coalesces each key into one 
output partition
+    // per side, leaving a key holding disproportionately many input 
partitions to a single
+    // join task. Like `OptimizeSkewedJoin`, a key group whose combined input 
partition count
+    // over both sides exceeds the threshold spreads the side holding more of 
its splits over
+    // output partitions holding about the advisory count each, and the other 
side replicates
+    // its group to each of those.
+    //
+    // A reducer regroups the keys into a space the per-key split counts do 
not cover, so the
+    // optimization stands down when either this pass derived one or the nodes 
carry one.
+    val hasReducer = leftReducers.isDefined || rightReducers.isDefined ||
+      carriesReducers(left) || carriesReducers(right)
+    // A marked layout holds rows of undeclared keys that only its own routing 
keeps co-located,
+    // and any grouping other than an identity one makes `GroupPartitionsExec` 
give up the keyed
+    // partitioning (see its `outputPartitioning`). Replicating a marked child 
to match a split
+    // is exactly such a grouping, and its one partition per declared key can 
only be the
+    // replicating side, so no key qualifies.
+    val markedChild = 
PartitioningCollection.keyedMarkerOf(leftPartitioning).contains(true) ||
+      PartitioningCollection.keyedMarkerOf(rightPartitioning).contains(true)
+    var skewedGroupSplits: Map[InternalRowComparableWrapper, (Int, Boolean)] = 
Map.empty
+    if (conf.v2BucketingSkewJoinEnabled && !hasReducer && !markedChild &&
+        mergedPartitionKeys.nonEmpty) {
+      val partitionsPerGroup = mergedPartitionKeys.map { case (key, _) =>
+        key -> (leftSplitsPerKey.getOrElse(key, 0) + 
rightSplitsPerKey.getOrElse(key, 0))
+      }
+      val median = Utils.median(partitionsPerGroup.map(_._2.toLong).toArray, 
alreadySorted = false)
+      val skewThreshold = 
conf.v2BucketingSkewJoinSkewedPartitionsPerGroupThreshold.toLong.max(
+        (median * conf.v2BucketingSkewJoinSkewedGroupFactor).toLong)
+      val advisory = 
conf.v2BucketingSkewJoinAdvisoryInputPartitionsPerOutputPartition
+      // Which side keeps its splits while the other replicates is gated by 
the join type: an
+      // inner join replicates either side, so the side holding more splits 
distributes them; a
+      // one-sided type forces the spread onto the side it cannot replicate; a 
type that may
+      // replicate neither leaves every key coalesced. Like 
`OptimizeSkewedJoin`, the spreading
+      // side's own count must also exceed the threshold, else spreading the 
small side of a
+      // lopsided key multiplies the large group's reads while every task 
still reads it whole.
+      // A spread landing on a single partition is the unsplit layout, so it 
is not marked.
+      def chooseSplit(nL: Int, nR: Int): Option[(Int, Boolean)] = {
+        val candidate =
+          (canReplicateLeft, canReplicateRight) match {
+            case (true, true) => Some(if (nL >= nR) (nL, true) else (nR, 
false))
+            case (false, true) => Some((nL, true))
+            case (true, false) => Some((nR, false))
+            case (false, false) => None
           }
+        candidate.flatMap { case (own, leftDistributes) =>
+          val numSplits = math.ceil(own.toDouble / advisory).toInt
+          Option.when(own > skewThreshold && numSplits > 1)((numSplits, 
leftDistributes))
         }
       }
+      skewedGroupSplits = partitionsPerGroup.iterator.collect {
+        case (key, total) if total > skewThreshold =>
+          chooseSplit(leftSplitsPerKey.getOrElse(key, 0), 
rightSplitsPerKey.getOrElse(key, 0))
+            .map(key -> _)
+      }.flatten.toMap
+    }
 
-      // Now we need to push-down the common partition information to the 
`GroupPartitionsExec`s.
-      newLeft = applyGroupPartitions(left, leftSpec.joinKeyPositions, 
mergedPartitionKeys,
-        leftReducers, distributePartitions = applyPartialClustering && 
!replicateLeftSide)
-      newRight = applyGroupPartitions(right, rightSpec.joinKeyPositions, 
mergedPartitionKeys,
-        rightReducers, distributePartitions = applyPartialClustering && 
!replicateRightSide)
+    // Nothing to align: the pair is compatible as it stands, the skew 
decision reserved no key,
+    // and partial clustering is off. The children go back untouched, so a 
pair without a skewed
+    // key pays no alignment nodes -- over a marked child an inserted grouping 
would cost the
+    // plan its keyed claim (SPARK-59050) -- and a re-run over a reduced 
alignment does not
+    // reach the rewrite below, which derives no reducers and would clear the 
ones the nodes
+    // carry. The config check, not `applyPartialClustering`, is the 
load-bearing term: with
+    // partial clustering enabled the branch follows its base contract even 
for a pair the
+    // optimization gated out.
+    if (compatibleAsIs && skewedGroupSplits.isEmpty &&
+        !conf.v2BucketingPartiallyClusteredDistributionEnabled) {
+      return Seq(left, right)
     }
 
-    if (compatibleAsIs || pushCommonValues) Some(Seq(newLeft, newRight)) else 
None
+    // Now we need to push-down the common partition information to the 
`GroupPartitionsExec`s.
+    // A skew-split key carries its own count and per-side modes; the rest 
take the side's
+    // uniform mode: the partial clustering replicate side, or none when 
clustering is off.
+    def expectedKeys(distributePartitions: Boolean, isLeft: Boolean): 
Seq[ExpectedPartitionKey] =
+      mergedPartitionKeys.map { case (key, numSplits) =>
+        skewedGroupSplits.get(key) match {
+          case Some((skewSplits, leftDistributes)) =>
+            val distribute = if (isLeft) leftDistributes else !leftDistributes
+            ExpectedPartitionKey(key, skewSplits, distribute, skewed = true)
+          case None => ExpectedPartitionKey(key, numSplits, 
distributePartitions)
+        }
+      }
+    val newLeft = applyGroupPartitions(left, leftSpec.joinKeyPositions,
+      expectedKeys(applyPartialClustering && !replicateLeftSide, isLeft = 
true), leftReducers)
+    val newRight = applyGroupPartitions(right, rightSpec.joinKeyPositions,
+      expectedKeys(applyPartialClustering && !replicateRightSide, isLeft = 
false), rightReducers)

Review Comment:
   [P2] Keep the split join compatible with plan validation
   
   These children report `isGrouped=false`, but the join still has 
`isSkewJoin=false` and requires `ClusteredDistribution`. Consequently 
`ValidateRequirements.validate` rejects every split SPJ, even though the two 
sides are aligned. This also affects other operators: 
`AdaptiveSparkPlanExec.optimizeQueryStage` validates the entire candidate plan 
before accepting an `AQEShuffleReadRule` change. In a native control combining 
this SPJ with an unrelated 40-row `GROUP BY id % 2` through `UNION ALL`, AQE 
coalesces the aggregate's 20 shuffle partitions to 1 with skew disabled (and 
with the parent planning classes), but leaves all 20 with skew enabled and 
partial clustering disabled. The results remain correct. Please represent the 
relaxed distribution requirement of a split SPJ, or otherwise make validation 
recognize its aligned children; marking their partitioning grouped would 
incorrectly describe the downstream layout.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -584,217 +582,322 @@ case class EnsureRequirements(
     // return `None` after logging a pushdown that does not happen.
     if (bestPair.isEmpty) return None
     val (leftSpec, rightSpec) = bestPair.get
-    val leftPartitioning = leftSpec.partitioning
-    val rightPartitioning = rightSpec.partitioning
 
     // We don't need to alter the existing or add new `GroupPartitionsExec` 
when the child
     // partitionings are not modified (projected) in specs and left and right 
side partitionings are
     // compatible with each other.
     val compatibleAsIs =
       bothUnprojected(leftSpec, rightSpec) && 
leftSpec.isCompatibleWith(rightSpec)
-    // Entering the push branch is the same as taking it. The keys agree for 
this pair by
-    // construction, since `agreeingPairs` filtered on exactly that and an 
empty result returned
-    // above, so what the branch pushes always applies.
-    val pushCommonValues =
-      (!compatibleAsIs || 
conf.v2BucketingPartiallyClusteredDistributionEnabled) &&
+    // The skew term keeps the branch on wherever a split could apply: over 
pairs the children
+    // loop already made compatible by coalescing duplicate keys, and over 
re-runs of the
+    // split-aligned plans the branch produced. A pair the branch finds 
nothing to reserve goes
+    // back untouched, see `alignedJoinChildren`.
+    val alignKeyGroups =
+      (!compatibleAsIs || 
conf.v2BucketingPartiallyClusteredDistributionEnabled ||
+        conf.v2BucketingSkewJoinEnabled) &&
         (conf.v2BucketingPushPartValuesEnabled ||
           conf.v2BucketingAllowKeysSubsetOfPartitionKeys)
-    if (pushCommonValues) {
-      logInfo("Pushing common partition values for storage-partitioned join")
-
-      // Partition expressions are compatible. Regardless of whether partition 
values
-      // match from both sides of children, we can calculate a superset of 
partition values and
-      // push-down to respective data sources so they can adjust their output 
partitioning by
-      // filling missing partition keys with empty partitions. As result, we 
can still avoid
-      // shuffle.
-      //
-      // For instance, if two sides of a join have partition expressions
-      // `day(a)` and `day(b)` respectively
-      // (the join query could be `SELECT ... FROM t1 JOIN t2 on t1.a = 
t2.b`), but
-      // with different partition values:
-      //   `day(a)`: [0, 1]
-      //   `day(b)`: [1, 2, 3]
-      // Following the case 2 above, we don't have to shuffle both sides, but 
instead can
-      // just push the common set of partition values: `[0, 1, 2, 3]` down to 
the two data
-      // sources.
-      val leftPartKeys = leftPartitioning.partitionKeys
-      val rightPartKeys = rightPartitioning.partitionKeys
-
-      val numLeftPartKeys = MDC(LogKeys.NUM_LEFT_PARTITION_VALUES, 
leftPartKeys.size)
-      val numRightPartKeys = MDC(LogKeys.NUM_RIGHT_PARTITION_VALUES, 
rightPartKeys.size)
-      logInfo(
-        log"""
-            |Left side # of partitions: $numLeftPartKeys
-            |Right side # of partitions: $numRightPartKeys
-            |""".stripMargin)
-
-      // in case of compatible but not identical partition expressions, we 
apply 'reduce'
-      // transforms to group one side's partitions as well as the common 
partition values
-      val (leftReducers, rightReducers) = leftSpec.reducersBothWays(rightSpec)
-      val (leftReducedDataTypes, leftReducedKeys) = leftReducers.fold(
-        (leftPartitioning.keyDataTypes, leftPartitioning.partitionKeys)
-      )(leftPartitioning.reduceKeys)
-      val (rightReducedDataTypes, rightReducedKeys) = rightReducers.fold(
-        (rightPartitioning.keyDataTypes, rightPartitioning.partitionKeys)
-      )(rightPartitioning.reduceKeys)
-      // The reduced types are the types of the key rows the merge below sees, 
and a side with no
-      // key row left answers for them from its layout, which a reduce writes 
them into. So the
-      // comparison is meaningful on both sides whether or not either has a 
key (SPARK-59176).
-      if (leftReducedDataTypes != rightReducedDataTypes) {
-        // The two lists are the erased ones, so a struct in the message 
prints positional field
-        // names. That is deliberate: the names do not decide where a key 
belongs, so printing the
-        // connector's own would point a reader at a difference that is not 
the cause.
-        throw 
QueryExecutionErrors.storagePartitionJoinIncompatibleReducedTypesError(
-          leftReducers = leftReducers,
-          leftReducedDataTypes = leftReducedDataTypes,
-          rightReducers = rightReducers,
-          rightReducedDataTypes = rightReducedDataTypes)
-      }
+    if (alignKeyGroups) {
+      Some(alignedJoinChildren(left, right, leftSpec, rightSpec, joinType, 
compatibleAsIs))
+    } else if (compatibleAsIs) {
+      Some(Seq(left, right))
+    } else {
+      None
+    }
+  }
 
-      val reducedKeyOrdering = 
KeyedPartitioning.groupedKeyRowOrdering(leftReducedDataTypes)
-        .on((t: InternalRowComparableWrapper) => t.row)
-
-      // merge values on both sides
-      var mergedPartitionKeys =
-        mergeAndDedupPartitions(leftReducedKeys, rightReducedKeys, joinType, 
reducedKeyOrdering)
-          .map((_, 1))
-
-      logInfo(log"After merging, there are " +
-        log"${MDC(LogKeys.NUM_PARTITIONS, mergedPartitionKeys.size)} 
partitions")
-
-      var replicateLeftSide = false
-      var replicateRightSide = false
-      var applyPartialClustering = false
-
-      // This means we allow partitions that are not clustered on their values,
-      // that is, multiple partitions with the same partition value. In the
-      // following, we calculate how many partitions that each distinct 
partition
-      // value has, and pushdown the information to scans, so they can adjust 
their
-      // final input partitions respectively.
-      if (conf.v2BucketingPartiallyClusteredDistributionEnabled) {
-        logInfo("Calculating partially clustered distribution for " +
-            "storage-partitioned join")
-
-        // Similar to `OptimizeSkewedJoin`, we need to check join type and 
decide
-        // whether partially clustered distribution can be applied. For 
instance, the
-        // optimization cannot be applied to a left outer join, where the left 
hand
-        // side is chosen as the side to replicate partitions according to 
stats.
-        // Otherwise, query result could be incorrect.
-        val canReplicateLeft = ShuffledJoin.canDuplicateLeftSide(joinType)
-        val canReplicateRight = ShuffledJoin.canDuplicateRightSide(joinType)
-
-        if (!canReplicateLeft && !canReplicateRight) {
-          logInfo(log"Skipping partially clustered distribution as it cannot 
be applied for " +
-            log"join type '${MDC(LogKeys.JOIN_TYPE, joinType)}'")
+  /**
+   * Aligns both sides of a storage-partitioned join on one shared, merged and 
deduped set of
+   * partition keys, through the [[GroupPartitionsExec]]s over the two 
children. The per-key
+   * layout is one of three, in increasing precedence:
+   *
+   * 1. Coalescing, the default: every key reserves one output partition per 
side.
+   * 2. Partial clustering (`partiallyClusteredDistribution.enabled`): one 
side keeps its splits
+   *    spread for every key while the other replicates its group to match.
+   * 3. Per-key skew split (`skewJoin.enabled`): a key over the skew threshold 
spreads the side
+   *    holding more of its splits and replicates the other, gated per key on 
what the join type
+   *    may replicate. Overrides the partial clustering on that key.
+   *
+   * When the sides' partition transforms are compatible but not identical, 
reducers bring one
+   * side's keys into the other's space before the merge.
+   */
+  private def alignedJoinChildren(
+      left: SparkPlan,
+      right: SparkPlan,
+      leftSpec: KeyedShuffleSpec,
+      rightSpec: KeyedShuffleSpec,
+      joinType: JoinType,
+      compatibleAsIs: Boolean): Seq[SparkPlan] = {
+    val leftPartitioning = leftSpec.partitioning
+    val rightPartitioning = rightSpec.partitioning
+
+    logInfo("Aligning join children on merged partition values for 
storage-partitioned join")
+
+    // Partition expressions are compatible. Regardless of whether partition 
values
+    // match from both sides of children, we can calculate a superset of 
partition values and
+    // push-down to respective data sources so they can adjust their output 
partitioning by
+    // filling missing partition keys with empty partitions. As result, we can 
still avoid
+    // shuffle.
+    //
+    // For instance, if two sides of a join have partition expressions
+    // `day(a)` and `day(b)` respectively
+    // (the join query could be `SELECT ... FROM t1 JOIN t2 on t1.a = t2.b`), 
but
+    // with different partition values:
+    //   `day(a)`: [0, 1]
+    //   `day(b)`: [1, 2, 3]
+    // Following the case 2 above, we don't have to shuffle both sides, but 
instead can
+    // just push the common set of partition values: `[0, 1, 2, 3]` down to 
the two data
+    // sources.
+    val leftPartKeys = leftPartitioning.partitionKeys
+    val rightPartKeys = rightPartitioning.partitionKeys
+
+    val numLeftPartKeys = MDC(LogKeys.NUM_LEFT_PARTITION_VALUES, 
leftPartKeys.size)
+    val numRightPartKeys = MDC(LogKeys.NUM_RIGHT_PARTITION_VALUES, 
rightPartKeys.size)
+    logInfo(
+      log"""
+          |Left side # of partitions: $numLeftPartKeys
+          |Right side # of partitions: $numRightPartKeys
+          |""".stripMargin)
+
+    // in case of compatible but not identical partition expressions, we apply 
'reduce'
+    // transforms to group one side's partitions as well as the common 
partition values
+    val (leftReducers, rightReducers) = leftSpec.reducersBothWays(rightSpec)
+    val (leftReducedDataTypes, leftReducedKeys) = leftReducers.fold(
+      (leftPartitioning.keyDataTypes, leftPartitioning.partitionKeys)
+    )(leftPartitioning.reduceKeys)
+    val (rightReducedDataTypes, rightReducedKeys) = rightReducers.fold(
+      (rightPartitioning.keyDataTypes, rightPartitioning.partitionKeys)
+    )(rightPartitioning.reduceKeys)
+    // The reduced types are the types of the key rows the merge below sees, 
and a side with no
+    // key row left answers for them from its layout, which a reduce writes 
them into. So the
+    // comparison is meaningful on both sides whether or not either has a key 
(SPARK-59176).
+    if (leftReducedDataTypes != rightReducedDataTypes) {
+      // The two lists are the erased ones, so a struct in the message prints 
positional field
+      // names. That is deliberate: the names do not decide where a key 
belongs, so printing the
+      // connector's own would point a reader at a difference that is not the 
cause.
+      throw 
QueryExecutionErrors.storagePartitionJoinIncompatibleReducedTypesError(
+        leftReducers = leftReducers,
+        leftReducedDataTypes = leftReducedDataTypes,
+        rightReducers = rightReducers,
+        rightReducedDataTypes = rightReducedDataTypes)
+    }
+
+    val reducedKeyOrdering = 
KeyedPartitioning.groupedKeyRowOrdering(leftReducedDataTypes)
+      .on((t: InternalRowComparableWrapper) => t.row)
+
+    // merge values on both sides
+    var mergedPartitionKeys =
+      mergeAndDedupPartitions(leftReducedKeys, rightReducedKeys, joinType, 
reducedKeyOrdering)
+        .map((_, 1))
+
+    logInfo(log"After merging, there are " +
+      log"${MDC(LogKeys.NUM_PARTITIONS, mergedPartitionKeys.size)} partitions")
+
+    var replicateLeftSide = false
+    var replicateRightSide = false
+    var applyPartialClustering = false
+
+    // The pre-alignment plan of each side and the grouping over it, read 
lazily by the
+    // partially clustered and skew-split decisions below: both need the split 
counts and side
+    // statistics a re-run has to reproduce, which the aligned reports no 
longer hold.
+    lazy val leftGrouping = innermostGroupPartition(left)
+    lazy val rightGrouping = innermostGroupPartition(right)
+    lazy val unwrappedLeft = leftGrouping.map(_._1.child).getOrElse(left)
+    lazy val unwrappedRight = rightGrouping.map(_._1.child).getOrElse(right)
+
+    // Per-key input partition splits of a side. The keys come from the node's 
child and the
+    // positions from the node itself, falling back to the spec's when there 
is no grouping: the
+    // node's positions index the raw partition keys, the spec's the projected 
report. The
+    // partitioning holds a `KeyedPartitioning`, else this side would have had 
no spec at all.
+    def splitsPerKey(
+        grouping: Option[(GroupPartitionsExec, SparkPlan => SparkPlan)],
+        unwrapped: SparkPlan,
+        spec: KeyedShuffleSpec): Map[InternalRowComparableWrapper, Int] = {
+      val keyed = unwrapped.outputPartitioning.asInstanceOf[Expression]
+        .collectFirst { case k: KeyedPartitioning => k }.get
+      val positions = 
grouping.flatMap(_._1.joinKeyPositions).orElse(spec.joinKeyPositions)
+      positions.fold(keyed.partitionKeys)(keyed.projectKeys(_)._2)
+        .groupBy(identity).view.mapValues(_.size).toMap
+    }
+    lazy val leftSplitsPerKey = splitsPerKey(leftGrouping, unwrappedLeft, 
leftSpec)
+    lazy val rightSplitsPerKey = splitsPerKey(rightGrouping, unwrappedRight, 
rightSpec)
+
+    // Whether replicating a side over the other's splits preserves the 
result. Partial
+    // clustering needs any side to be replicable; the skew split is gated per 
key, since a join
+    // type admitting one side still forbids the other (a left outer join 
replicates the right
+    // side only).
+    val canReplicateLeft = ShuffledJoin.canDuplicateLeftSide(joinType)
+    val canReplicateRight = ShuffledJoin.canDuplicateRightSide(joinType)
+
+    // This means we allow partitions that are not clustered on their values,
+    // that is, multiple partitions with the same partition value. In the
+    // following, we calculate how many partitions that each distinct partition
+    // value has, and pushdown the information to scans, so they can adjust 
their
+    // final input partitions respectively.
+    if (conf.v2BucketingPartiallyClusteredDistributionEnabled) {
+      logInfo("Calculating partially clustered distribution for " +
+          "storage-partitioned join")
+
+      // Similar to `OptimizeSkewedJoin`, we need to check join type and decide
+      // whether partially clustered distribution can be applied. For 
instance, the
+      // optimization cannot be applied to a left outer join, where the left 
hand
+      // side is chosen as the side to replicate partitions according to stats.
+      // Otherwise, query result could be incorrect.
+      if (!canReplicateLeft && !canReplicateRight) {
+        logInfo(log"Skipping partially clustered distribution as it cannot be 
applied for " +
+          log"join type '${MDC(LogKeys.JOIN_TYPE, joinType)}'")
+      } else {
+        val leftLink = unwrappedLeft.logicalLink
+        val rightLink = unwrappedRight.logicalLink
+
+        replicateLeftSide = if (
+          leftLink.isDefined && rightLink.isDefined &&
+              leftLink.get.stats.sizeInBytes > 1 &&
+              rightLink.get.stats.sizeInBytes > 1) {
+          val leftLinkStatsSizeInBytes = 
MDC(LogKeys.LEFT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
+            leftLink.get.stats.sizeInBytes)
+          val rightLinkStatsSizeInBytes = 
MDC(LogKeys.RIGHT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
+            rightLink.get.stats.sizeInBytes)
+          logInfo(
+            log"""
+               |Using plan statistics to determine which side of join to fully
+               |cluster partition values:
+               |Left side size (in bytes): $leftLinkStatsSizeInBytes
+               |Right side size (in bytes): $rightLinkStatsSizeInBytes
+               |""".stripMargin)
+          leftLink.get.stats.sizeInBytes < rightLink.get.stats.sizeInBytes
         } else {
-          // The pre-alignment plan of each side and the grouping this rule 
inserted over it,
-          // are read once: the statistics and the original partition keys 
below come from the
-          // plan, the positions projecting them from the grouping.
-          val leftGrouping = innermostGroupPartition(left)
-          val rightGrouping = innermostGroupPartition(right)
-          val unwrappedLeft = leftGrouping.map(_._1.child).getOrElse(left)
-          val unwrappedRight = rightGrouping.map(_._1.child).getOrElse(right)
-
-          val leftLink = unwrappedLeft.logicalLink
-          val rightLink = unwrappedRight.logicalLink
-
-          replicateLeftSide = if (
-            leftLink.isDefined && rightLink.isDefined &&
-                leftLink.get.stats.sizeInBytes > 1 &&
-                rightLink.get.stats.sizeInBytes > 1) {
-            val leftLinkStatsSizeInBytes = 
MDC(LogKeys.LEFT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
-              leftLink.get.stats.sizeInBytes)
-            val rightLinkStatsSizeInBytes = 
MDC(LogKeys.RIGHT_LOGICAL_PLAN_STATS_SIZE_IN_BYTES,
-              rightLink.get.stats.sizeInBytes)
-            logInfo(
-              log"""
-                 |Using plan statistics to determine which side of join to 
fully
-                 |cluster partition values:
-                 |Left side size (in bytes): $leftLinkStatsSizeInBytes
-                 |Right side size (in bytes): $rightLinkStatsSizeInBytes
-                 |""".stripMargin)
-            leftLink.get.stats.sizeInBytes < rightLink.get.stats.sizeInBytes
-          } else {
-            // As a simple heuristic, we pick the side with fewer partitions 
to apply the
-            // grouping & replication of partitions. The counts read the
-            // pre-alignment plans, for the same reason the statistics do: on 
a re-run both
-            // aligned reports hold the same number of keys, so comparing them 
decides nothing.
-            // This also changes a first pass, which compared the aligned 
report's distinct
-            // keys rather than the splits behind them.
-            logInfo("Using number of partitions to determine which side of 
join " +
-                "to fully cluster partition values")
-            
PartitioningCollection.numKeyedPartitions(unwrappedLeft.outputPartitioning)
-              .getOrElse(leftPartKeys.size) <
-              
PartitioningCollection.numKeyedPartitions(unwrappedRight.outputPartitioning)
-              .getOrElse(rightPartKeys.size)
+          // As a simple heuristic, we pick the side with fewer partitions to 
apply the
+          // grouping & replication of partitions. The counts read the
+          // pre-alignment plans, for the same reason the statistics do: on a 
re-run both
+          // aligned reports hold the same number of keys, so comparing them 
decides nothing.
+          // This also changes a first pass, which compared the aligned 
report's distinct
+          // keys rather than the splits behind them.
+          logInfo("Using number of partitions to determine which side of join 
" +
+              "to fully cluster partition values")
+          
PartitioningCollection.numKeyedPartitions(unwrappedLeft.outputPartitioning)
+            .getOrElse(leftPartKeys.size) <
+            
PartitioningCollection.numKeyedPartitions(unwrappedRight.outputPartitioning)
+            .getOrElse(rightPartKeys.size)
+        }
+
+        replicateRightSide = !replicateLeftSide
+
+        // Similar to skewed join, we need to check the join type to see 
whether replication
+        // of partitions can be applied. For instance, replication should not 
be allowed for
+        // the left-hand side of a left outer join.
+        if (replicateLeftSide && !canReplicateLeft) {
+          logInfo(log"Left-hand side is picked but cannot be applied to join 
type " +
+            log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
+            log"distribution.")
+          replicateLeftSide = false
+        } else if (replicateRightSide && !canReplicateRight) {
+          logInfo(log"Right-hand side is picked but cannot be applied to join 
type " +
+            log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
+            log"distribution.")
+          replicateRightSide = false
+        } else {
+          // In partially clustered distribution, we should use un-grouped 
partition values:
+          // the side that keeps its splits sizes every key from its own 
pre-alignment counts.
+          val numExpectedPartitions =
+            if (replicateLeftSide) rightSplitsPerKey else leftSplitsPerKey
+
+          mergedPartitionKeys = mergedPartitionKeys.map { case (key, numParts) 
=>
+            (key, numExpectedPartitions.getOrElse(key, numParts))
           }
 
-          replicateRightSide = !replicateLeftSide
-
-          // Similar to skewed join, we need to check the join type to see 
whether replication
-          // of partitions can be applied. For instance, replication should 
not be allowed for
-          // the left-hand side of a left outer join.
-          if (replicateLeftSide && !canReplicateLeft) {
-            logInfo(log"Left-hand side is picked but cannot be applied to join 
type " +
-              log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
-              log"distribution.")
-            replicateLeftSide = false
-          } else if (replicateRightSide && !canReplicateRight) {
-            logInfo(log"Right-hand side is picked but cannot be applied to 
join type " +
-              log"'${MDC(LogKeys.JOIN_TYPE, joinType)}'. Skipping partially 
clustered " +
-              log"distribution.")
-            replicateRightSide = false
-          } else {
-            // In partially clustered distribution, we should use un-grouped 
partition values.
-            // The child and the positions projecting its keys come from the 
same grouping:
-            // the keys from the node's child, the positions from the node 
itself, falling back
-            // to the spec's when there is no grouping. Like in 
`applyGroupPartitions`, the
-            // node's positions 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) =
-              if (replicateLeftSide) {
-                (unwrappedRight,
-                  rightGrouping.flatMap(_._1.joinKeyPositions)
-                    .orElse(rightSpec.joinKeyPositions))
-              } else {
-                (unwrappedLeft,
-                  leftGrouping.flatMap(_._1.joinKeyPositions)
-                    .orElse(leftSpec.joinKeyPositions))
-              }
-            // The pre-alignment plan of the side that keeps its splits: its 
partitioning
-            // still holds the original partition keys, one per input split.
-            val originalPartitioning =
-              
partiallyClusteredChild.outputPartitioning.asInstanceOf[Expression]
-            // `outputPartitioning` is either a `PartitioningCollection` or a 
`KeyedPartitioning`
-            // otherwise `createKeyedShuffleSpecs()` would have returned 
nothing.
-            val originalKeyedPartitioning =
-              originalPartitioning.collectFirst { case k: KeyedPartitioning => 
k }.get
-            val projectedOriginalPartitionKeys = partiallyClusteredPositions
-              .fold(originalKeyedPartitioning.partitionKeys)(
-                originalKeyedPartitioning.projectKeys(_)._2)
-
-            val numExpectedPartitions =
-              
projectedOriginalPartitionKeys.groupBy(identity).view.mapValues(_.size)
-
-            mergedPartitionKeys = mergedPartitionKeys.map { case (key, 
numParts) =>
-              (key, numExpectedPartitions.getOrElse(key, numParts))
-            }
+          logInfo(log"After applying partially clustered distribution, there 
are " +
+            log"${MDC(LogKeys.NUM_PARTITIONS, 
mergedPartitionKeys.map(_._2).sum)} partitions.")
+          applyPartialClustering = true
+        }
+      }
+    }
 
-            logInfo(log"After applying partially clustered distribution, there 
are " +
-              log"${MDC(LogKeys.NUM_PARTITIONS, 
mergedPartitionKeys.map(_._2).sum)} partitions.")
-            applyPartialClustering = true
+    // Per-key skew split: the default alignment coalesces each key into one 
output partition
+    // per side, leaving a key holding disproportionately many input 
partitions to a single
+    // join task. Like `OptimizeSkewedJoin`, a key group whose combined input 
partition count
+    // over both sides exceeds the threshold spreads the side holding more of 
its splits over
+    // output partitions holding about the advisory count each, and the other 
side replicates
+    // its group to each of those.
+    //
+    // A reducer regroups the keys into a space the per-key split counts do 
not cover, so the
+    // optimization stands down when either this pass derived one or the nodes 
carry one.
+    val hasReducer = leftReducers.isDefined || rightReducers.isDefined ||
+      carriesReducers(left) || carriesReducers(right)
+    // A marked layout holds rows of undeclared keys that only its own routing 
keeps co-located,
+    // and any grouping other than an identity one makes `GroupPartitionsExec` 
give up the keyed
+    // partitioning (see its `outputPartitioning`). Replicating a marked child 
to match a split
+    // is exactly such a grouping, and its one partition per declared key can 
only be the
+    // replicating side, so no key qualifies.
+    val markedChild = 
PartitioningCollection.keyedMarkerOf(leftPartitioning).contains(true) ||
+      PartitioningCollection.keyedMarkerOf(rightPartitioning).contains(true)
+    var skewedGroupSplits: Map[InternalRowComparableWrapper, (Int, Boolean)] = 
Map.empty
+    if (conf.v2BucketingSkewJoinEnabled && !hasReducer && !markedChild &&
+        mergedPartitionKeys.nonEmpty) {
+      val partitionsPerGroup = mergedPartitionKeys.map { case (key, _) =>
+        key -> (leftSplitsPerKey.getOrElse(key, 0) + 
rightSplitsPerKey.getOrElse(key, 0))
+      }
+      val median = Utils.median(partitionsPerGroup.map(_._2.toLong).toArray, 
alreadySorted = false)
+      val skewThreshold = 
conf.v2BucketingSkewJoinSkewedPartitionsPerGroupThreshold.toLong.max(
+        (median * conf.v2BucketingSkewJoinSkewedGroupFactor).toLong)
+      val advisory = 
conf.v2BucketingSkewJoinAdvisoryInputPartitionsPerOutputPartition
+      // Which side keeps its splits while the other replicates is gated by 
the join type: an
+      // inner join replicates either side, so the side holding more splits 
distributes them; a
+      // one-sided type forces the spread onto the side it cannot replicate; a 
type that may
+      // replicate neither leaves every key coalesced. Like 
`OptimizeSkewedJoin`, the spreading
+      // side's own count must also exceed the threshold, else spreading the 
small side of a
+      // lopsided key multiplies the large group's reads while every task 
still reads it whole.
+      // A spread landing on a single partition is the unsplit layout, so it 
is not marked.
+      def chooseSplit(nL: Int, nR: Int): Option[(Int, Boolean)] = {
+        val candidate =
+          (canReplicateLeft, canReplicateRight) match {
+            case (true, true) => Some(if (nL >= nR) (nL, true) else (nR, 
false))
+            case (false, true) => Some((nL, true))
+            case (true, false) => Some((nR, false))
+            case (false, false) => None
           }
+        candidate.flatMap { case (own, leftDistributes) =>
+          val numSplits = math.ceil(own.toDouble / advisory).toInt
+          Option.when(own > skewThreshold && numSplits > 1)((numSplits, 
leftDistributes))
         }
       }
+      skewedGroupSplits = partitionsPerGroup.iterator.collect {
+        case (key, total) if total > skewThreshold =>
+          chooseSplit(leftSplitsPerKey.getOrElse(key, 0), 
rightSplitsPerKey.getOrElse(key, 0))
+            .map(key -> _)
+      }.flatten.toMap
+    }
 
-      // Now we need to push-down the common partition information to the 
`GroupPartitionsExec`s.
-      newLeft = applyGroupPartitions(left, leftSpec.joinKeyPositions, 
mergedPartitionKeys,
-        leftReducers, distributePartitions = applyPartialClustering && 
!replicateLeftSide)
-      newRight = applyGroupPartitions(right, rightSpec.joinKeyPositions, 
mergedPartitionKeys,
-        rightReducers, distributePartitions = applyPartialClustering && 
!replicateRightSide)
+    // Nothing to align: the pair is compatible as it stands, the skew 
decision reserved no key,
+    // and partial clustering is off. The children go back untouched, so a 
pair without a skewed
+    // key pays no alignment nodes -- over a marked child an inserted grouping 
would cost the
+    // plan its keyed claim (SPARK-59050) -- and a re-run over a reduced 
alignment does not
+    // reach the rewrite below, which derives no reducers and would clear the 
ones the nodes
+    // carry. The config check, not `applyPartialClustering`, is the 
load-bearing term: with
+    // partial clustering enabled the branch follows its base contract even 
for a pair the
+    // optimization gated out.
+    if (compatibleAsIs && skewedGroupSplits.isEmpty &&
+        !conf.v2BucketingPartiallyClusteredDistributionEnabled) {
+      return Seq(left, right)
     }
 
-    if (compatibleAsIs || pushCommonValues) Some(Seq(newLeft, newRight)) else 
None
+    // Now we need to push-down the common partition information to the 
`GroupPartitionsExec`s.
+    // A skew-split key carries its own count and per-side modes; the rest 
take the side's
+    // uniform mode: the partial clustering replicate side, or none when 
clustering is off.
+    def expectedKeys(distributePartitions: Boolean, isLeft: Boolean): 
Seq[ExpectedPartitionKey] =
+      mergedPartitionKeys.map { case (key, numSplits) =>
+        skewedGroupSplits.get(key) match {
+          case Some((skewSplits, leftDistributes)) =>
+            val distribute = if (isLeft) leftDistributes else !leftDistributes
+            ExpectedPartitionKey(key, skewSplits, distribute, skewed = true)

Review Comment:
   [P2] Avoid replicated reads when downstream grouping serializes the split
   
   For the new `skewed group split keeps an aggregate over the join exact` 
query (`JOIN ... GROUP BY i.id`), the aggregate adds a third 
`GroupPartitionsExec` above the split join. That node uses a narrow 
`CoalescedRDD`, so all of a key's split join partitions execute sequentially in 
the same final task. With the PR's 8+1 left / 1+1 right input-split fixture, a 
native SQL probe with partial clustering and AQE disabled measured 2 tasks and 
2 right-scan rows with skew off, versus the same 2 tasks and 9 right-scan rows 
with skew on. The bare-join control does gain parallelism (2 to 9 tasks), 
isolating the downstream regrouping as the cause. Please skip this split when a 
downstream narrow grouping will collapse it, or preserve an execution boundary 
that lets the split tasks run independently. Smaller sequential buffers may 
still help memory, but this common aggregate shape pays the replicated reads 
without gaining task parallelism.



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