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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -219,6 +232,18 @@ case class GroupPartitionsExec(
     PartitionGrouping(partitions, isGrouped, isCollapsed)
   }
 
+  /**
+   * Whether this node's grouping leaves every partition where it was, i.e. 
output partition i
+   * holds exactly input partition i. That is the only grouping that keeps a 
marked layout's
+   * undeclared rows at hash(key) % numPartitions. The `forall` stops at the 
first moved
+   * partition, so a reorder or coalesce is rejected without a full scan.
+   */
+  @transient private lazy val identityGrouping: Boolean =

Review Comment:
   **Finding 23.** This answers the order and per-partition-membership halves 
of 
[r3920336519](https://github.com/apache/spark/pull/58339#discussion_r3920336519),
 not the count half.
   
   `alignToExpectedKeys` iterates `expectedPartitionKeys`, so a key the child 
declares but the merged set does not is never emitted at all. When the dropped 
ones are trailing, every kept group still satisfies `single == outputIndex`, so 
`identityGrouping` is true while `numPartitions` shrank. The child's undeclared 
rows sit at `nonNegativeMod(hash, childCount)` and the retained marker now 
claims `nonNegativeMod(hash, groupedCount)`.
   
   The merged set is a strict subset of a marked side's own keys on three 
routes in `mergeAndDedupPartitions`: `Inner` under 
`spark.sql.sources.v2.bucketing.partition.filter.enabled` intersects, and the 
`LeftOuter` and `RightOuter` arms take one side's keys. A marked `[1, 2, 3]` 
against an unmarked partner `[1, 2]` under the filter gives `expected = [1, 
2]`, groups `[(1, [0]), (2, [1])]`, `identityGrouping = true`, and two 
partitions where the hash was taken over three.
   
   I re-checked the enumeration I gave at round 5 and it still holds, so this 
is latent rather than live: on each of those routes the shrunk side is not the 
side whose partitioning the join reports. `Inner` with one marked side clears 
the mixed collection, `Inner` with both marked requires equal key sequences so 
the intersection is the identity, `LeftOuter` shrinks only the right, and 
`RightOuter` mirrors it. It closes in this same `lazy val`:
   
   ```scala
     @transient private lazy val identityGrouping: Boolean =
       grouping.partitions.size == child.outputPartitioning.numPartitions &&
         grouping.partitions.zipWithIndex.forall {
           case ((_, Seq(single)), outputIndex) => single == outputIndex
           case _ => false
         }
   ```
   
   The doc sentence above it can then say "leaves every partition where it was, 
and keeps all of them", which is what the marked contract needs.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -578,12 +582,42 @@ case class CoalescedNullAwareHashPartitioning(
  *                    partitioning this one was derived from onto the same 
key, so one key here can
  *                    stand for several of the original ones. Sticky. See "Key 
Collapse" above for
  *                    what it gates and how it travels.
+ * @param mayContainUnknownPartitionKeys Whether the data may contain rows 
whose partition key is
+ *                                 not among the declared `partitionKeys`. 
`KeyGroupedPartitioner`
+ *                                 routes such rows by a deterministic hash 
when a side is
+ *                                 re-shuffled onto this partitioning (see
+ *                                 `KeyedShuffleSpec.createPartitioning`), so 
co-location holds
+ *                                 for whole keys only: two marked 
partitionings declaring the
+ *                                 same keys in the same order and using the 
same partition
+ *                                 function per position still pair (equal 
undeclared keys hash
+ *                                 to the same partition), but a row of an 
undeclared key sits in
+ *                                 the partition of some other declared key, 
away from rows
+ *                                 sharing a subset of its columns. 
`satisfies` and
+ *                                 `KeyedShuffleSpec.areKeysCompatible` 
therefore accept a marked
+ *                                 partitioning only for full-key clustering, 
never for a subset
+ *                                 of its partition columns and never for a 
global ordering
+ *                                 across several partitions. Two carry rules: 
(1) a node that
+ *                                 changes the declared key set must drop the 
keyed partitioning,
+ *                                 whether it coarsens it (key-dropping 
projection, reducer,
+ *                                 join-key projection) or expands it over a 
marked leg (a union,
+ *                                 where another leg may declare exactly the 
key that leg holds
+ *                                 out-of-set); (2) a marked member beside an 
unmarked sibling is

Review Comment:
   **Finding 24.** Rule (2) lost its verb in the reorder: "(2) a marked member 
beside an unmarked sibling is agreement is enforced by 
`PartitioningCollection`".
   
   The clause after it also says the opposite of your reply on 
[r3915661530](https://github.com/apache/spark/pull/58339#discussion_r3915661530).
 There you wrote that the clearing keeps the OR from spreading a spurious 
marker. Here it reads "clears the markers first -- precision, so the OR spreads 
a spurious marker onto the accurate side instead of a genuine one", which 
parses as clearing *causing* the spread.
   
   ```scala
    *                                 out-of-set); (2) marker agreement is 
enforced by
    *                                 `PartitioningCollection`: the constructor 
requires it and
    *                                 `fromPartitionings` normalizes by OR, so 
members are
    *                                 uniformly marked or unmarked and 
consumers may read one
    *                                 member. `ShuffledJoin`'s `InnerLike` arm, 
the only site that
    *                                 meets a marked input with an unmarked 
one, clears the markers
    *                                 first. That is precision, not the 
guarantee: without it the
    *                                 OR would spread the spurious marker onto 
the accurate side.
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -78,6 +78,18 @@ case class GroupPartitionsExec(
         // data types match the reduced partition keys for the 
identity-vs-transform and
         // single-side-transform reducers; for the both-sides-reduce shape no 
single transform
         // describes the keys (see `KeyedShuffleSpec.reducersBothWays`).
+        //
+        // A marked claim pins undeclared rows to hash(key) % numPartitions 
(see
+        // `KeyedPartitioning.mayContainUnknownPartitionKeys`). Only an 
identity grouping keeps
+        // that relationship: any other grouping -- a reorder, a coalesce, a 
resize, or the
+        // collapse a reduction applies -- moves those rows. Clearing only the 
marker would
+        // misreport the undeclared rows that remain, so give up the keyed 
partitioning at the
+        // physical output count (one per group, padding included) that a 
parent's
+        // `PartitioningCollection` requires for uniformity. 
`identityGrouping` is a lazy val, so
+        // repeated `outputPartitioning` calls scan it at most once.
+        if (PartitioningCollection.keyedMarkerOf(p).contains(true) && 
!identityGrouping) {

Review Comment:
   **Finding 22.** The give-up runs after `EnsureRequirements` has already 
committed to the pairing, so the join it was planned for ends up with a child 
that no longer satisfies its required distribution.
   
   `checkKeyGroupCompatible` decides `isCompatible` from `areKeysCompatible`, 
inserts the two `GroupPartitionsExec`s, and returns `Some(Seq(newLeft, 
newRight))`. That sets `areChildrenCompatible`, and 
`EnsureRequirements.scala:251` then leaves both children alone. Nothing re-asks 
them afterwards. On your own new test the second join's left child reports 
`UnknownPartitioning(4)` while the `SortMergeJoinExec` above it requires 
`ClusteredDistribution(id)`.
   
   Measured in a review worktree at this head, by printing inside the AQE-off 
arm of `SPARK-59050: SPJ: regrouping a marked layout must not keep the 
unknown-keyed claim`:
   
   ```
   gpe outputPartitionings = UnknownPartitioning, KeyedPartitioning
   ValidateRequirements.validate = false
   ```
   
   Narrowing the clause back to `reducers.isDefined && 
PartitioningCollection.keyedMarkerOf(p).contains(true) && !identityGrouping` 
and re-running the same print gives `KeyedPartitioning, KeyedPartitioning` and 
`validate = true`. So the flip is this clause's.
   
   `ValidateRequirements` is not decoration. It is AQE's veto on every 
`AQEShuffleReadRule` (`AdaptiveSparkPlanExec.scala:209` reverts the rule when 
it returns false) and on `OptimizeSkewedJoin` (`OptimizeSkewedJoin.scala:269`). 
A stage holding such a join can lose coalescing, local read and skew handling 
with only a `logDebug` to show it.
   
   I could not exhibit that loss, so I am not resting the severity on it. This 
query's three exchanges are all keyed, so `CoalesceShufflePartitions` has 
nothing to work on and both arms print `shuffleReads=0`. I also wrapped the 
query in a hash-shuffled join to give the top stage coalescable inputs. There 
AQE did coalesce (`shuffleReads=2 coalesced=2`) and `validate` came back true, 
because the give-up did not survive into that plan shape. The experiment that 
would settle it is a plan carrying both the give-up and a coalescable hash 
exchange in one stage.
   
   The minimum ask is to say it here: that the node deliberately under-reports, 
and that a plan containing it does not pass `ValidateRequirements`. As written 
the comment reads as if the give-up were free.
   
   I am not asking for more than that in this PR. Every other coarsening site 
here closes at the producer, and this is the only consumer-side gate left, but 
moving it needs `EnsureRequirements` to know the permutation that 
`alignToExpectedKeys` derives, which is a refactor rather than a bugfix. If you 
would rather close it now, `checkKeyGroupCompatible` already holds both new 
children and both distributions, so it can decline the pairing it just built:
   
   ```scala
         newLeft = applyGroupPartitions(left, ...)
         newRight = applyGroupPartitions(right, ...)
         // A marked side whose regrouping is not the identity gives up its 
keyed partitioning, so
         // the pairing above no longer holds. Fall back to the shuffle rather 
than leave the join
         // with a child that does not satisfy its distribution.
         if 
(!newLeft.outputPartitioning.satisfies(requiredChildDistribution.head) ||
             
!newRight.outputPartitioning.satisfies(requiredChildDistribution(1))) {
           return None
         }
   ```
   
   That is not free either: it sends the marked side back to the ordinary 
shuffle path, which changes the exchange count your new test pins. Your call 
which trade you want. I have not measured the new count.
   



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