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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -145,10 +158,26 @@ case class GroupPartitionsExec(
 
     val keyToPartitionIndices = reducedKeys.zipWithIndex.groupMap(_._1)(_._2)
 
+    // Whether this node collapses keys: does any key it keeps stand for more 
than one of the
+    // child's own partition keys? Counting the child's *keys* rather than its 
partitions is what
+    // tells a collapse from a source reporting several splits per key, and 
asking it of the keys
+    // this node keeps is what tells it from `alignToExpectedKeys` dropping 
keys, which merges
+    // nothing. Ask the key groups, not the partitions finally emitted: 
`distributePartitions`
+    // spreads a group's splits over one partition each, which would hide the 
merge, and
+    // replication would ask about the same group repeatedly.
+    val childKeys = keyedPartitioning.partitionKeys.toIndexedSeq
+    def coversSeveralChildKeys(indices: Seq[Int]): Boolean =
+      indices.map(childKeys).distinct.size > 1
+
     if (expectedPartitionKeys.isDefined) {
-      alignToExpectedKeys(keyToPartitionIndices)
+      val (alignedPartitions, grouped) = 
alignToExpectedKeys(keyToPartitionIndices)
+      val keptGroups = expectedPartitionKeys.get.map { case (key, _) =>

Review Comment:
   This is your second form now, the `exists` over `expectedPartitionKeys`, so 
there is no intermediate Seq and it short-circuits.
   
   I went your first way to begin with and computed the collapse bit inside 
`alignToExpectedKeys`, then moved it back out. Inside, the aligner had to grow 
a mutable buffer and a third tuple element that it never used itself, and its 
doc had to explain a return value that belonged to the caller.
   
   What the current form gives up is the single lookup site you were after. 
`keyToPartitionIndices.get(key)` now appears twice in the file, and if the 
aligner's key matching changed, the two could drift. One thing did get better 
with the move: both branches of the scan take their groups from 
`keyToPartitionIndices`, so a padded expected key cannot produce an empty group 
at all, and the `getOrElse(key, Seq.empty)` plus its guard are gone.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -643,12 +686,23 @@ case class KeyedPartitioning(
       val joinKeyPositions = 
result.keyPositions.map(_.nonEmpty).zipWithIndex.filter(_._1).map(_._2)
       val projectedExpressions = joinKeyPositions.map(expressions)
       val projectedKeys = projectKeys(joinKeyPositions)._2
-      // Sort the distinct projected keys the same way `GroupPartitionsExec` 
does (both sort with
-      // `KeyedPartitioning.groupedKeyRowOrdering`). Otherwise, when only the 
keyed side is grouped
-      // and the other side is re-shuffled using this spec, the two 
`KeyedPartitioning`s carry the
-      // same keys in a different order and 
`PartitioningCollection.fromPartitionings` rejects them.
-      val projectedPartitioning =
-        new KeyedPartitioning(projectedExpressions, projectedKeys, isGrouped = 
false).toGrouped
+      // `toGrouped` dedups and sorts the keys the same way 
`GroupPartitionsExec` does (both sort
+      // with `KeyedPartitioning.groupedKeyRowOrdering`). Otherwise, when only 
the keyed side is
+      // grouped and the other side is re-shuffled using this spec, the two 
`KeyedPartitioning`s
+      // carry the same keys in a different order and 
`PartitioningCollection.fromPartitionings`
+      // rejects them. Its `distinct` is also the only one needed here: the 
partition count it
+      // leaves is the projected distinct key count the collapse test asks for.
+      val grouped = new KeyedPartitioning(
+        projectedExpressions, projectedKeys, isGrouped = false, isCollapsed = 
false).toGrouped
+      // Projecting onto the operation keys can collapse keys in its own 
right. Dropping no position
+      // cannot, so the counts are only compared when one was dropped. The 
gate in
+      // `groupedSatisfies` is bypassed while this config is on, so the flag 
decides nothing here
+      // today, but it travels with the partitioning, and leaving a producer 
to launder it is how
+      // the protection went missing.
+      val projectedCollapsed = isCollapsed ||
+        (joinKeyPositions.length < expressions.length &&
+          collapsesOnProjection(grouped.numPartitions))
+      val projectedPartitioning = grouped.copy(isCollapsed = 
projectedCollapsed)

Review Comment:
   Moot now. `createShuffleSpec` does not copy at all any more, it is 
`project(joinKeyPositions).toGrouped`, and `project` decides the flag in the 
constructor call.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -491,14 +491,40 @@ case class CoalescedNullAwareHashPartitioning(
  *     `ClusteredDistribution`, this is the only route by which a grouped KP 
satisfies one, and no
  *     grouping is involved: the keys are already unique.
  *
- * That second caller is why the narrowing guard in `groupedSatisfies()` is a 
conjunction with
- * `!isGrouped`. A narrowed KP whose projected keys stayed distinct is 
grouped, and dropping the
- * `!isGrouped` term would stop it from satisfying a `ClusteredDistribution` 
and cost it a shuffle,
- * even though grouping it would merge nothing.
+ * That second caller is why the collapse guard in `groupedSatisfies()` is a 
conjunction with
+ * `!isGrouped`. A collapsed KP can end up grouped -- by 
`GroupPartitionsExec`, or by reducing its
+ * keys onto a coarser transform -- and dropping the `!isGrouped` term would 
stop such a KP from
+ * satisfying a `ClusteredDistribution` and cost it a shuffle, even though 
grouping it would merge
+ * nothing.
  *
  * For `OrderedDistribution`, `GroupPartitionsExec` must also sort the 
partition keys to meet the
  * ordering requirement.
  *
+ * == Key Collapse ==
+ * Two things happen to partition keys, and only the first is a loss of 
granularity:
+ *
+ * - '''Key collapse''': a projection or a reduction maps two keys that were 
distinct onto the same
+ *   new key. `[(1, 'a'), (1, 'b'), (2, 'c')]` projected onto the first 
position gives `[1, 1, 2]`:
+ *   three distinct keys became two. `isCollapsed` records this.
+ * - '''Grouping''': `GroupPartitionsExec` physically combines the partitions 
that share a key.
+ *   `[1, 1, 2]` becomes `[1, 2]`. `isGrouped` says the keys are unique, 
however they got that way:
+ *   a source with natively unique keys reports it too.
+ *
+ * Grouping after a collapse is what produces a partition holding more data 
than any the source
+ * declared -- the two `1` partitions above came from different `(1, 'a')` and 
`(1, 'b')` keys -- so
+ * it needs `allowKeysSubsetOfPartitionKeys`. Grouping without a collapse only 
merges partitions
+ * that already shared a key (a source reporting several splits per key, or a 
union of children that
+ * overlap), and needs no opt-in. `OrderedDistribution` is not gated at all: 
`GroupPartitionsExec`
+ * pads that path out to the expected split counts rather than coalescing, so 
nothing is merged.
+ *
+ * A collapsed partitioning is still kept rather than dropped to 
`UnknownPartitioning`, because

Review Comment:
   Done, in your words:
   
   - **Collapsed and ungrouped**: duplicate keys remain, so grouping them would 
merge partitions the source held apart. `mayGroupToSatisfy()` refuses a 
`ClusteredDistribution` unless the config is on.
   - **Collapsed and grouped**: the keys are already unique, so grouping merges 
nothing and `satisfies()` accepts a `ClusteredDistribution` whatever the config 
says. A partitioning reaches this state by being grouped with the config on, or 
by having its keys reduced onto a coarser transform.
   
   The paragraph that mixed the two states is gone. What follows the bullets is 
only what is common to both, which is why a collapsed partitioning is still 
reported rather than dropped to `UnknownPartitioning`.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/AliasAwareOutputExpression.scala:
##########
@@ -137,12 +137,24 @@ trait PartitioningPreservingUnaryExecNode extends 
UnaryExecNode
       if (projectablePositions.length == numPositions) keySource.partitionKeys
       else keySource.projectKeys(projectablePositions)._2
 
-    val isGrouped = sharedKeys.distinct.size == sharedKeys.size
-    // A KP is narrowed if this node drops positions, or if the input KPs were 
already narrowed
-    // (i.e. came from a finer-grained partitioning). The flag must be sticky: 
a subsequent
-    // PartitioningPreservingUnaryExecNode that passes all positions through 
would otherwise
-    // recompute isNarrowed=false, silently dropping the protection.
-    val isNarrowed = projectablePositions.length < numPositions || 
keySource.isNarrowed
+    val distinctSharedKeys = sharedKeys.distinct
+    val isGrouped = distinctSharedKeys.size == sharedKeys.size
+    // This projection collapses keys when it maps keys that were distinct in 
the input
+    // onto the same projected key -- dropping positions is not enough on its 
own, since the
+    // projected keys can stay just as distinct as the originals. The flag is 
sticky: a subsequent
+    // PartitioningPreservingUnaryExecNode that passes all positions through 
must not recompute it
+    // as false and drop the protection, and no projection can make a 
partitioning finer again.
+    //
+    // Both cheap terms come first: an inherited flag or a projection that 
drops no position
+    // settles the question without counting distinct keys. A pass-through 
projection cannot
+    // collapse anything, since it keeps the input's keys as they are.
+    //
+    // The inherited flag is read from all inputs rather than from the key 
source alone. A
+    // `PartitioningCollection` normalizes it across its members, so the two 
agree today; reading
+    // all of them keeps this producer correct without depending on that.
+    val isCollapsed = kps.exists(_.isCollapsed) ||

Review Comment:
   Update after a rewrite, because the helper I named here no longer exists.
   
   `collapsesOnProjection` is gone, and the rule it held moved inside 
`KeyedPartitioning.project`, which both producers now call for the whole 
projection. So the rule is in one place rather than the three you started from, 
and there is no helper left that a future producer could forget to call.
   
   `GroupPartitionsExec` still answers the question its own way, from the key 
groups it keeps, for the reason in my earlier reply.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -643,12 +676,17 @@ case class KeyedPartitioning(
       val joinKeyPositions = 
result.keyPositions.map(_.nonEmpty).zipWithIndex.filter(_._1).map(_._2)
       val projectedExpressions = joinKeyPositions.map(expressions)
       val projectedKeys = projectKeys(joinKeyPositions)._2
+      // Projecting onto the operation keys can collapse keys in its own 
right, which is
+      // what the key count comparison catches. The gate in `groupedSatisfies` 
is bypassed while
+      // this config is on, so the flag decides nothing here today, but it 
travels with the
+      // partitioning and leaving a producer to launder it is how the 
protection went missing.
+      val projectedCollapsed = isCollapsed || projectedKeys.distinct.length < 
distinctKeyCount

Review Comment:
   Update, and the intermediate state was worse than what I told you here. 
Extracting the projection into `KeyedPartitioning.project` brought the second 
`distinct` back for a while, because `project` counted the projected keys and 
`toGrouped` then deduped the same list again.
   
   It is one pass now, and not a `distinct` at all. `project` walks the 
projected keys alongside the keys they came from and fills a `projectedKey -> 
sourceKey` map. A second, different source key on an existing projected key is 
the collapse, and it also means the projected keys are not unique, so the walk 
stops there. The source's own distinct count is not computed any more, and the 
source keys are never hashed, only compared where a projected key repeats.
   
   Measured on the worst case for that test, a 50k-split partitioning with 25k 
distinct keys and 12-position keys, projected ten times over, 20 evaluations: 
345 ms for the old provenance formula, 1291 ms for this one, 2137 ms for the 
two-`distinct` form. `toGrouped` after it also skips its own dedup when the 
keys are already unique.
   



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