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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/ShuffledJoin.scala:
##########
@@ -106,9 +106,15 @@ trait ShuffledJoin extends JoinCodegenSupport {
       partitionings.map {
         case partitioning: Partitioning with Expression
             if 
PartitioningCollection.keyedMarkerOf(partitioning).contains(true) =>
+          // One cleared layout for the whole input, because a collection's 
members must share the
+          // layout by reference. They already share one, so the first 
member's answers for all.
+          var cleared: KeyLayout = null
           partitioning.transform {
             case k: KeyedPartitioning if k.mayContainUnknownPartitionKeys =>
-              k.copy(mayContainUnknownPartitionKeys = false)
+              if (cleared == null) {
+                cleared = k.layout.copy(mayContainUnknownPartitionKeys = false)

Review Comment:
   Small planning-time regression here. `cleared` is a fresh `KeyLayout`, so it 
is never `eq` the keyed side's layout, and `fromPartitionings.intern` no longer 
takes its return-as-is path for an inner join with one shuffled side: every 
`outputPartitioning` call (a `def`, re-evaluated by `EnsureRequirements`, 
`ValidateRequirements` and every ancestor join) rebuilds one member via 
`keyed.copy(layout = canonicalLayout)`. When the marked side happens to be the 
first member and the other side is a nested `PartitioningCollection`, the whole 
subtree is rebuilt and `checkKeyedPartitioningInvariant` re-runs, so O(subtree) 
where the base was O(1). In the base, `k.copy(mayContainUnknownPartitionKeys = 
false)` kept the `partitionKeys` reference and both flags agreed, so `intern` 
returned the member untouched. The `fromPartitionings` comment still promises 
"returned as it is ... O(1) per level".
   
   One cheap way back: when exactly one side is unmarked, reuse that side's 
layout object as `cleared` if `k.layout.copy(mayContainUnknownPartitionKeys = 
false) == unmarkedLayout` (O(1), since the case-class `==` short-circuits on 
the shared `partitionKeys` reference), and fall back to the fresh copy 
otherwise. Not a blocker; alternatively just qualify the comment in 
`fromPartitionings`.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -876,19 +919,28 @@ case class KeyedPartitioning(
 
 object KeyedPartitioning {
   /**
-   * Creates a KeyedPartitioning with isGrouped computed from the partition 
keys.
-   * Use this when creating a new KeyedPartitioning from scratch (e.g., from a 
data source).
+   * Creates a KeyedPartitioning with isGrouped computed from the partition 
keys. Use this when
+   * creating a new KeyedPartitioning from scratch (e.g., from a data source).
+   *
+   * `sortKeys` sorts them first, at the types they will be compared at. A 
data source reports its
+   * splits in its own order, and a keyed side and a side re-shuffled onto it 
have to agree on the
+   * order or `PartitioningCollection.fromPartitionings` refuses them. Sorting 
here rather than in
+   * the caller is what keeps the type list and the ordering to one 
derivation: both come off the
+   * factory that builds the keys.
    */
   def apply(
       expressions: Seq[Expression],
-      partitionKeys: Seq[InternalRow]): KeyedPartitioning = {
-    val dataTypes = expressions.map(_.dataType)
-    val comparableKeyWrapperFactory =
-      
InternalRowComparableWrapper.getInternalRowComparableWrapperFactory(dataTypes)
-    val comparablePartitionKeys = 
partitionKeys.map(comparableKeyWrapperFactory)
+      partitionKeys: Seq[InternalRow],
+      sortKeys: Boolean = false): KeyedPartitioning = {

Review Comment:
   Nit: `sortKeys = true` has exactly one caller (`DataSourceV2ScanExecBase`), 
and `KeyedPartitioning.groupedKeyRowOrdering(dataTypes)` already returns this 
same factory ordering 
(`getInternalRowComparableWrapperFactory(dataTypes).ordering`, cache-backed) 
and is what `GroupPartitionsExec.groupAndSortByKeys` and `EnsureRequirements` 
already sort with. The scan could do 
`.sorted(KeyedPartitioning.groupedKeyRowOrdering(exprs.map(_.dataType)))` and 
call the unchanged two-arg `apply`; both resolve to 
`orderingCache.get(comparableTypes(...))`, so the "one derivation" property 
holds by construction either way, and the sort contract is visibly shared with 
`GroupPartitionsExec` by helper name rather than by a boolean every other 
caller defaults.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -567,17 +570,24 @@ case class GroupPartitionsExec(
 }
 
 /**
- * What a [[GroupPartitionsExec]] computes once and reports from several 
members. The last two
- * fields count the alignment's effect on the reads of the child's splits (see
+ * What a [[GroupPartitionsExec]] computes once and reports from several 
members: which of the
+ * child's partitions each of its own is built from, and the layout that 
describes them. The last
+ * two fields count the alignment's effect on the reads of the child's splits 
(see
  * `alignToExpectedKeys`), and are 0 outside the alignment path.
  */
 private case class PartitionGrouping(

Review Comment:
   Nit: `dataTypes`, `isGrouped` and `isCollapsed` are read only through 
`layout` (every other `grouping.` access in the file is `partitions`, 
`keysRewritten` or the two counters), and `outputPartitioning` re-reads the 
marker separately and re-applies it with `if (marked) grouping.layout.copy(...) 
else grouping.layout`, plus a comment explaining the split. `childKp` is in 
scope where the grouping is built, so the layout could be built once there with 
`mayContainUnknownPartitionKeys = childKp.mayContainUnknownPartitionKeys`, and 
`PartitionGrouping` could hold `layout: KeyLayout` instead of the three fields. 
The `UnknownPartitioning` give-up branch never reports the layout, so a pre-set 
marker is unobservable there, and both branches already force `grouping`. That 
removes the `marked` if/else and keeps one place to touch when `KeyLayout` 
grows a field.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/ShuffledJoin.scala:
##########
@@ -106,9 +106,15 @@ trait ShuffledJoin extends JoinCodegenSupport {
       partitionings.map {
         case partitioning: Partitioning with Expression
             if 
PartitioningCollection.keyedMarkerOf(partitioning).contains(true) =>
+          // One cleared layout for the whole input, because a collection's 
members must share the
+          // layout by reference. They already share one, so the first 
member's answers for all.
+          var cleared: KeyLayout = null

Review Comment:
   Nit: the inner `if k.mayContainUnknownPartitionKeys` guard is dead here, 
since the outer `keyedMarkerOf(partitioning).contains(true)` already read the 
shared marker and the constructor's `layout eq` invariant means every keyed 
member carries it, and the `var cleared = null` first-visit dance is a lazy way 
of computing the representative's layout once. It could match the style 
`GroupPartitionsExec.outputPartitioning` already uses, computing the layout 
outside the transform:
   
   ```scala
   val cleared = partitioning
     .collectFirst { case k: KeyedPartitioning => k.layout }.get
     .copy(mayContainUnknownPartitionKeys = false)
   partitioning.transform { case k: KeyedPartitioning => k.copy(layout = 
cleared) }
   ```
   
   (or widen `representativeOf` from `private[physical]` to `private[sql]`, 
like `keyedMarkerOf` next to it).
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1229,41 +1275,57 @@ object PartitioningCollection {
     representativeOf(p).map(_.mayContainUnknownPartitionKeys)
 
   /**
-   * Builds a [[PartitioningCollection]], unifying the `partitionKeys` 
reference across all
-   * [[KeyedPartitioning]]s (including those in nested collections). Use this 
when combining
-   * independently-computed partitionings (e.g. join `outputPartitioning`) 
where
-   * `KeyedPartitioning.partitionKeys` are structurally equal but may not be 
reference-equal.
+   * Builds a [[PartitioningCollection]], unifying the [[KeyLayout]] reference 
across all
+   * [[KeyedPartitioning]]s, including those in nested collections. Use this 
when combining
+   * independently-computed partitionings, such as a join's 
`outputPartitioning`, whose layouts
+   * describe the same partitions but are not the same object.
    *
    * Note: this can't be implemented with `TreeNode.transform`.
    */
   def fromPartitionings(partitionings: Seq[Partitioning]): 
PartitioningCollection = {
     // See the class doc for why the flags are normalized by OR rather than 
required to agree. One
-    // representative per member is enough, because every collection agrees on 
the flags
-    // internally by this same construction, and only a member that disagrees 
is rebuilt.
+    // representative per member is enough, because every collection agrees 
internally by this same
+    // construction, and only a member that disagrees is rebuilt.
     val anyCollapsed = 
partitionings.exists(representativeOf(_).exists(_.isCollapsed))
     val anyUnknownKeys =
       
partitionings.exists(representativeOf(_).exists(_.mayContainUnknownPartitionKeys))
 
-    var canonicalKeys: Seq[InternalRowComparableWrapper] = null
+    var canonicalLayout: KeyLayout = null
     // A partitioning with no `KeyedPartitioning` in it has nothing to 
normalize, and one that
-    // already agrees on the keys and both flags is returned as it is. That is 
what keeps
-    // repeated `outputPartitioning` computations over deeply nested 
collections (e.g. chains of
-    // same-key joins) O(1) per level.
+    // already holds the canonical layout is returned as it is. That is what 
keeps repeated
+    // `outputPartitioning` computations over deeply nested collections (e.g. 
chains of same-key
+    // joins) O(1) per level.
     def intern(p: Partitioning): Partitioning = representativeOf(p) match {
       case None => p
       case Some(representative) =>
-        if (canonicalKeys == null) canonicalKeys = representative.partitionKeys
-        if ((representative.partitionKeys eq canonicalKeys) &&
-            representative.isCollapsed == anyCollapsed &&
-            representative.mayContainUnknownPartitionKeys == anyUnknownKeys) {
+        if (canonicalLayout == null) {
+          val layout = representative.layout
+          canonicalLayout =
+            if (layout.isCollapsed == anyCollapsed &&
+                layout.mayContainUnknownPartitionKeys == anyUnknownKeys) {
+              layout
+            } else {
+              layout.copy(
+                isCollapsed = anyCollapsed, mayContainUnknownPartitionKeys = 
anyUnknownKeys)
+            }
+        }
+        if (representative.layout eq canonicalLayout) {
           p
         } else {
-          require(representative.partitionKeys == canonicalKeys,
+          require(representative.partitionKeys == 
canonicalLayout.partitionKeys,
             "All KeyedPartitionings in a PartitioningCollection must have 
equal partitionKeys")
+          // Whether the keys are unique is a property of the keys, so two 
layouts over equal keys
+          // that disagree on it cannot both be right.
+          require(representative.isGrouped == canonicalLayout.isGrouped,
+            "All KeyedPartitionings in a PartitioningCollection must agree on 
isGrouped")
+          // Interning replaces a member's layout whole, so a member that 
describes another key
+          // space would be silently retyped. Two empty key lists compare 
equal whatever they
+          // describe, which is the case the clause above them cannot see.
+          require(representative.keyDataTypes == canonicalLayout.dataTypes,

Review Comment:
   Nit: the "same key space" predicate (`keyDataTypes ==` alongside 
`partitionKeys ==`), and the "two empty key lists compare equal whatever they 
describe" rationale, now live in two places: the requires here (1315 and 1324, 
which also mix the two names `keyDataTypes` and `dataTypes` for the same field) 
and `KeyedShuffleSpec.isCompatibleWith`, where the last round grew the comment 
further. Since the empty-key argument is what motivated `KeyLayout` in the 
first place, it might belong on it, e.g. `KeyLayout.describesSameKeys(other: 
KeyLayout): Boolean = dataTypes == other.dataTypes && partitionKeys == 
other.partitionKeys`, with the rationale written once. `intern` would 
`require(representative.layout.describesSameKeys(canonicalLayout))` plus the 
separate `isGrouped` require (which is deliberately not part of 
`isCompatibleWith`), and `isCompatibleWith` would call the same method, so the 
next site that compares keys cannot forget the type clause.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1941,16 +2018,18 @@ case class KeyedShuffleSpec(
         te.copy(children = te.children.map(_ => clustering(positionSet.head)))
       case (_, positionSet) => clustering(positionSet.head)
     }
-    // The shuffled side is laid out on this side's partition keys, so it 
inherits the flag. That
-    // is conservative rather than strictly true, and it can only ever add a 
shuffle: a later
-    // grouping of the shared key set carries the collapsed side's risk.
+    // The shuffled side is laid out on this side's partitions, so it shares 
their layout, with one
+    // change. The child re-shuffled onto it may hold keys outside the 
declared set, so every
+    // partitioning produced here carries the marker (see [[KeyLayout]]'s 
`@param`). Only the
+    // shuffle loop reaches this call, and it carries no same-domain subset 
proof, so marking is
+    // sound; it is conservative where the child's keys are in fact a known 
subset (identity key
+    // [1] inside declared [1, 2]), a precision this path does not attempt.
     //
-    // The child re-shuffled onto this layout may hold keys outside the 
declared set, so every
-    // partitioning produced here carries the marker (see the `@param`). Only 
the shuffle loop
-    // reaches this call, and it carries no same-domain subset proof, so 
marking is sound; it is
-    // conservative where the child's keys are in fact a known subset 
(identity key [1] inside
-    // declared [1, 2]), a precision this path does not attempt.
-    partitioning.copy(expressions = newExpressions, 
mayContainUnknownPartitionKeys = true)
+    // There is nothing to decide about `isCollapsed`: a later grouping of the 
shared key set
+    // carries the same risk whichever side reports it.
+    partitioning
+      .copy(expressions = newExpressions)
+      .withLayout(_.copy(mayContainUnknownPartitionKeys = true))

Review Comment:
   Nit: `copy(expressions = ...).withLayout(...)` builds an intermediate 
`KeyedPartitioning` (a `TreeNode`, so it captures `origin` and allocates the 
lazy holders) that is discarded immediately. `partitioning.copy(expressions = 
newExpressions, layout = 
partitioning.layout.copy(mayContainUnknownPartitionKeys = true))` does it in 
one node. Planning-time only, once per shuffle candidate, so purely a nit; feel 
free to keep the `withLayout` idiom if you prefer the readability.
   



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