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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1361,11 +1378,20 @@ trait ShuffleSpec {
    */
   def createPartitioning(clustering: Seq[Expression]): Partitioning =
     throw SparkUnsupportedOperationException()
+
+  override final def flatten: Seq[LeafShuffleSpec] = Seq(this)
 }
 
-case object SinglePartitionShuffleSpec extends ShuffleSpec {
-  override def isCompatibleWith(other: ShuffleSpec): Boolean = {
-    other.numPartitions == 1
+case object SinglePartitionShuffleSpec extends LeafShuffleSpec {
+  override def isCompatibleWith(other: ShuffleSpec): Boolean = other match {
+    case leaf: LeafShuffleSpec => leaf.numPartitions == 1
+    // `forall`, not the `exists` the other specs use for a collection. They 
ask whether *some*
+    // member matches them and then plan on that member; this one never names 
a member, since
+    // `canCreatePartitioning` is false, so the answer has to hold for 
whichever member the plan
+    // settles on. The counts are projected ones as everywhere here, so a 
child whose every member
+    // projects to one answers yes even while holding more partitions of its 
own. Members can only
+    // disagree when the subset config projects them onto different key sets.
+    case ShuffleSpecCollection(specs) => specs.forall(isCompatibleWith)

Review Comment:
   The `forall` here makes `isCompatibleWith` asymmetric with the collection 
side, which answers `exists` (`ShuffleSpecCollection(Seq(h1, 
h10)).isCompatibleWith(SinglePartitionShuffleSpec)` is true while the reverse 
is false), and the trait doc says Spark assumes symmetry. In practice nothing 
observable changes: only `KeyedShuffleSpec` can make the members disagree on 
`numPartitions`, and it has no `SinglePartitionShuffleSpec` case, so that 
direction is already false.
   
   Also worth noting in the comment: `EnsureRequirements` never reaches this 
branch, since a spec with `canCreatePartitioning == false` is never `best`. The 
one production caller that can hit it with a collection on the `other` side is 
`ValidateRequirements` (`specs.tail.forall(_.isCompatibleWith(specs.head))`), 
where the stricter `forall` is the safer answer.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -523,28 +524,75 @@ case class EnsureRequirements(
     var newLeft = left
     var newRight = right
 
-    val specs = Seq(left, right).zip(requiredChildDistribution).map { case (p, 
d) =>
-      if (!d.isInstanceOf[ClusteredDistribution]) return None
-      val cd = d.asInstanceOf[ClusteredDistribution]
-      val specOpt = createKeyedShuffleSpec(p.outputPartitioning, cd)
-      if (specOpt.isEmpty) return None
-      specOpt.get
-    }
+    def candidatesFor(plan: SparkPlan, required: Distribution): 
Seq[KeyedShuffleSpec] =
+      required match {
+        case cd: ClusteredDistribution => 
createKeyedShuffleSpecs(plan.outputPartitioning, cd)
+        case _ => Nil
+      }
+    val leftCandidates = candidatesFor(left, requiredChildDistribution.head)
+    val rightCandidates = candidatesFor(right, requiredChildDistribution(1))
+    if (leftCandidates.isEmpty || rightCandidates.isEmpty) return None
+
+    // A spec carries no `joinKeyPositions` exactly when its partitioning is 
the child's own member,
+    // so this asks whether `isCompatible` below can take the pair as it 
stands, without a
+    // `GroupPartitionsExec` on either side - unless partially clustered 
distribution is on, which
+    // sends every pairing through the push branch anyway.
+    def bothUnprojected(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Boolean =
+      l.joinKeyPositions.isEmpty && r.joinKeyPositions.isEmpty
+
+    // How many key groups the pushdown below would leave this pair. 
`mergeAndDedupPartitions`
+    // keeps one side's keys and drops the other's for the filtered one-sided 
join types, and there
+    // the dropped side's count says nothing, so rank on the side that 
survives. The arms that
+    // really merge have no cheap answer, so they take the larger of the two 
counts. Keep the join
+    // types here in step with `mergeAndDedupPartitions`.
+    def rank(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Int =
+      if (!conf.getConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED)) {
+        l.numPartitions.max(r.numPartitions)
+      } else {
+        joinType match {
+          case LeftOuter | LeftAnti | LeftSingle | ExistenceJoin(_) => 
l.numPartitions
+          case RightOuter => r.numPartitions
+          case _ => l.numPartitions.max(r.numPartitions)
+        }
+      }
 
-    val leftSpec = specs.head
-    val rightSpec = specs(1)
+    // Each side may offer several members, and the right one is the one the 
other side can pair
+    // with, which neither side can tell on its own. So pick the pair rather 
than a member per side,
+    // and rank the pairs that agree on the keys by the parallelism they 
offer, the same trade
+    // `ensureDistributionAndOrdering` makes between children when it picks 
`bestSpecOpt`.
+    //
+    // Two things keep `rank` from being what the join actually gets, both on 
the merging arms.
+    // `InnerLike` and `LeftSemi` intersect under 
`v2BucketingPartitionFilterEnabled`, and an
+    // intersection is not monotone in member granularity: members cover 
different clustering keys
+    // rather than nested ones, so a finer pair can rank above a coarser one 
and still meet the
+    // other side in fewer groups. And a union does not merely exceed the rank 
either, because
+    // `reduceKeys` runs between this pick and the merge and collapses 
distinct keys, so a
+    // `bucket(16)` side reduced onto `bucket(8)` brings 8 keys to a merge its 
spec ranked at 16.
+    // Ranking on the merged count would match what is delivered, at the cost 
of merging every
+    // candidate pair, which would also raise 
`storagePartitionJoinIncompatibleReducedTypesError`
+    // for pairs that are never chosen.
+    //
+    // Ties go to a pair both children report as it stands, to keep the 
no-grouping-node path. A
+    // projected count never exceeds the physical one, so nothing outranks 
such a pair, but a coarse
+    // member whose projected count happens to equal it ties, and enumeration 
order would decide.
+    val agreeingPairs = for {
+      l <- leftCandidates
+      r <- rightCandidates
+      if l.areKeysCompatible(r)
+    } yield (l, r)
+    val (leftSpec, rightSpec) = agreeingPairs
+      .maxByOption { case (l, r) => (rank(l, r), bothUnprojected(l, r)) }
+      // No agreeing pair means every pair fails the checks below, so the 
method returns `None`
+      // whichever one it reports. Reporting each side's first member keeps 
that path byte for byte
+      // what the per-side pick produced, `logInfo` included.
+      .getOrElse((leftCandidates.head, rightCandidates.head))
     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.
-    // Left and right `outputPartitioning` is a `PartitioningCollection` or a 
`KeyedPartitioning`
-    // otherwise `createKeyedShuffleSpec()` would have returned `None`.
-    var isCompatible =
-      left.outputPartitioning.asInstanceOf[Expression].exists(_ == 
leftPartitioning) &&
-      right.outputPartitioning.asInstanceOf[Expression].exists(_ == 
rightPartitioning) &&
-      leftSpec.isCompatibleWith(rightSpec)
+    var isCompatible = bothUnprojected(leftSpec, rightSpec) && 
leftSpec.isCompatibleWith(rightSpec)
     if ((!isCompatible || 
conf.v2BucketingPartiallyClusteredDistributionEnabled) &&

Review Comment:
   Nit: for the chosen pair `areKeysCompatible` is now evaluated three times: 
once in the pairing filter, once inside `isCompatibleWith` above, and again in 
`isCompatible = leftSpec.areKeysCompatible(rightSpec)` just below. Not 
expensive, but the last one is known to be true whenever an agreeing pair was 
found, so it could be skipped (or the pairing result reused) if the fallback 
above becomes an early return.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -523,28 +524,75 @@ case class EnsureRequirements(
     var newLeft = left
     var newRight = right
 
-    val specs = Seq(left, right).zip(requiredChildDistribution).map { case (p, 
d) =>
-      if (!d.isInstanceOf[ClusteredDistribution]) return None
-      val cd = d.asInstanceOf[ClusteredDistribution]
-      val specOpt = createKeyedShuffleSpec(p.outputPartitioning, cd)
-      if (specOpt.isEmpty) return None
-      specOpt.get
-    }
+    def candidatesFor(plan: SparkPlan, required: Distribution): 
Seq[KeyedShuffleSpec] =
+      required match {
+        case cd: ClusteredDistribution => 
createKeyedShuffleSpecs(plan.outputPartitioning, cd)
+        case _ => Nil
+      }
+    val leftCandidates = candidatesFor(left, requiredChildDistribution.head)
+    val rightCandidates = candidatesFor(right, requiredChildDistribution(1))
+    if (leftCandidates.isEmpty || rightCandidates.isEmpty) return None
+
+    // A spec carries no `joinKeyPositions` exactly when its partitioning is 
the child's own member,
+    // so this asks whether `isCompatible` below can take the pair as it 
stands, without a
+    // `GroupPartitionsExec` on either side - unless partially clustered 
distribution is on, which
+    // sends every pairing through the push branch anyway.
+    def bothUnprojected(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Boolean =
+      l.joinKeyPositions.isEmpty && r.joinKeyPositions.isEmpty
+
+    // How many key groups the pushdown below would leave this pair. 
`mergeAndDedupPartitions`
+    // keeps one side's keys and drops the other's for the filtered one-sided 
join types, and there
+    // the dropped side's count says nothing, so rank on the side that 
survives. The arms that
+    // really merge have no cheap answer, so they take the larger of the two 
counts. Keep the join
+    // types here in step with `mergeAndDedupPartitions`.
+    def rank(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Int =
+      if (!conf.getConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED)) {
+        l.numPartitions.max(r.numPartitions)
+      } else {
+        joinType match {
+          case LeftOuter | LeftAnti | LeftSingle | ExistenceJoin(_) => 
l.numPartitions
+          case RightOuter => r.numPartitions
+          case _ => l.numPartitions.max(r.numPartitions)
+        }
+      }
 
-    val leftSpec = specs.head
-    val rightSpec = specs(1)
+    // Each side may offer several members, and the right one is the one the 
other side can pair
+    // with, which neither side can tell on its own. So pick the pair rather 
than a member per side,
+    // and rank the pairs that agree on the keys by the parallelism they 
offer, the same trade
+    // `ensureDistributionAndOrdering` makes between children when it picks 
`bestSpecOpt`.
+    //
+    // Two things keep `rank` from being what the join actually gets, both on 
the merging arms.
+    // `InnerLike` and `LeftSemi` intersect under 
`v2BucketingPartitionFilterEnabled`, and an
+    // intersection is not monotone in member granularity: members cover 
different clustering keys
+    // rather than nested ones, so a finer pair can rank above a coarser one 
and still meet the
+    // other side in fewer groups. And a union does not merely exceed the rank 
either, because
+    // `reduceKeys` runs between this pick and the merge and collapses 
distinct keys, so a
+    // `bucket(16)` side reduced onto `bucket(8)` brings 8 keys to a merge its 
spec ranked at 16.
+    // Ranking on the merged count would match what is delivered, at the cost 
of merging every
+    // candidate pair, which would also raise 
`storagePartitionJoinIncompatibleReducedTypesError`
+    // for pairs that are never chosen.
+    //
+    // Ties go to a pair both children report as it stands, to keep the 
no-grouping-node path. A
+    // projected count never exceeds the physical one, so nothing outranks 
such a pair, but a coarse
+    // member whose projected count happens to equal it ties, and enumeration 
order would decide.
+    val agreeingPairs = for {
+      l <- leftCandidates
+      r <- rightCandidates
+      if l.areKeysCompatible(r)
+    } yield (l, r)
+    val (leftSpec, rightSpec) = agreeingPairs
+      .maxByOption { case (l, r) => (rank(l, r), bothUnprojected(l, r)) }
+      // No agreeing pair means every pair fails the checks below, so the 
method returns `None`
+      // whichever one it reports. Reporting each side's first member keeps 
that path byte for byte
+      // what the per-side pick produced, `logInfo` included.
+      .getOrElse((leftCandidates.head, rightCandidates.head))

Review Comment:
   Minor: since no agreeing pair means every pair fails both `isCompatibleWith` 
and the `areKeysCompatible` check in the push branch, this could simply be an 
early `return None` instead of falling back to the two heads. That is simpler, 
and it also avoids emitting `"Pushing common partition values for 
storage-partitioned join"` for a join where nothing is pushed. I understand the 
intent of keeping the old path byte for byte, but with the pairing in place 
there is little reason to keep it.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to