dongjoon-hyun commented on code in PR #58351:
URL: https://github.com/apache/spark/pull/58351#discussion_r3881703132
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -68,13 +68,27 @@ case class GroupPartitionsExec(
child.outputPartitioning match {
case p: Partitioning with Expression =>
// There can be multiple `KeyedPartitioning`s in an output
partitioning of a join, but they
- // can only differ in `expressions`; their `partitionKeys` reference
is shared (enforced by
- // `PartitioningCollection`), so `groupedPartitions` is computed only
once.
+ // can only differ in `expressions`; their `partitionKeys` reference
and `isCollapsed` flag
+ // are shared (enforced by `PartitioningCollection`), so both
`groupedPartitions` and the
+ // new flag are computed once, outside the transform.
val partitionKeys = groupedPartitions.map(_._1)
+ // `isCollapsed` is sticky: grouping removes the duplicate keys, but
it does not make this
+ // partitioning any finer than the layout it came from. Projecting
onto the join key
+ // positions, or reducing the keys onto a coarser transform, can
collapse keys in its own
+ // right, which is what the key count comparison catches. Compare
against this side's
+ // own key count, not `partitionKeys`: that list is the one both join
sides agreed on, so it
+ // may be missing keys this side had (partition filtering) or repeat
them (padding).
+ // One member is enough: they share the `partitionKeys` reference and
the flag, so they
+ // also share `distinctKeyCount`. Reading them all would force that
count -- a pass over
+ // the keys -- once per member for the same answer.
+ val isCollapsed = PartitioningCollection.flatten(p).collectFirst {
+ case k: KeyedPartitioning =>
+ k.isCollapsed || projectedDistinctKeyCount < k.distinctKeyCount
Review Comment:
`projectedDistinctKeyCount` is taken from `keyToPartitionIndices.size`
*before* `alignToExpectedKeys` prunes to the join-agreed key set, so a collapse
confined entirely to keys that the intersection then filters out still marks
the output collapsed, even though every surviving partition maps 1:1 to a
source partition.
Reachable on the reducer path with `allowCompatibleTransforms` +
`partitionFilter` on and the subset opt-in off: ids `[0, 4, 5]` reduced onto
buckets `[0, 0, 1]` with bucket 0 pruned by the other side gives `2 < 3`,
flagging an output whose only surviving partition was never merged. Since the
flag is sticky, a later union + GROUP BY then hits the `isCollapsed &&
!isGrouped` gate and pays a shuffle the pre-PR code planned shuffle-free.
The new "filtering partition keys out is not a key collapse" test covers
pruning *without* a collapse, but not this variant where the pruned keys are
themselves the collapsed ones. Perf-only, never wrong results, but it
contradicts the flag's documented meaning ("one partition here can stand for
several of the original ones").
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -68,13 +68,27 @@ case class GroupPartitionsExec(
child.outputPartitioning match {
case p: Partitioning with Expression =>
// There can be multiple `KeyedPartitioning`s in an output
partitioning of a join, but they
- // can only differ in `expressions`; their `partitionKeys` reference
is shared (enforced by
- // `PartitioningCollection`), so `groupedPartitions` is computed only
once.
+ // can only differ in `expressions`; their `partitionKeys` reference
and `isCollapsed` flag
+ // are shared (enforced by `PartitioningCollection`), so both
`groupedPartitions` and the
+ // new flag are computed once, outside the transform.
val partitionKeys = groupedPartitions.map(_._1)
+ // `isCollapsed` is sticky: grouping removes the duplicate keys, but
it does not make this
+ // partitioning any finer than the layout it came from. Projecting
onto the join key
+ // positions, or reducing the keys onto a coarser transform, can
collapse keys in its own
+ // right, which is what the key count comparison catches. Compare
against this side's
+ // own key count, not `partitionKeys`: that list is the one both join
sides agreed on, so it
+ // may be missing keys this side had (partition filtering) or repeat
them (padding).
+ // One member is enough: they share the `partitionKeys` reference and
the flag, so they
+ // also share `distinctKeyCount`. Reading them all would force that
count -- a pass over
+ // the keys -- once per member for the same answer.
+ val isCollapsed = PartitioningCollection.flatten(p).collectFirst {
Review Comment:
`outputPartitioning` is a `def`, so this block re-runs on every call:
`flatten` allocates a Seq just to `collectFirst` one KP, and
`k.distinctKeyCount` is forced on a fresh child KP instance each time
(`DataSourceV2ScanExecBase.outputPartitioning` rebuilds its `KeyedPartitioning`
per call), so the lazy val never amortizes -- an O(#splits) distinct pass per
consultation during EnsureRequirements/validation/AQE.
Computing the flag once inside the memoized `groupedPartitionsTuple` --
which already `collectFirst`s the same child KP and holds
`keyToPartitionIndices.size` -- is semantically identical, and would also
remove the unreachable `.getOrElse(false)` (`groupedPartitions`, forced just
above via `partitionKeys`, throws when no KP exists) and the second, divergent
first-KP lookup idiom in this file (line 148 uses plain `collectFirst`).
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1445,7 +1513,13 @@ case class KeyedShuffleSpec(
te.copy(children = te.children.map(_ => clustering(positionSet.head)))
case (_, positionSet) => clustering(positionSet.head)
}
- KeyedPartitioning(newExpressions, partitioning.partitionKeys,
partitioning.isGrouped)
+ // The shuffled side is laid out on this side's partition keys, so it
inherits the flag.
+ // Strictly nothing collapsed on this side -- its partitions are what a
hash partitioning would
+ // give -- so this is deliberate conservatism: the two sides are
co-located on one key set, and
+ // a later grouping of that key set carries the collapsed side's risk. It
can only add shuffles,
+ // never remove one.
+ KeyedPartitioning(newExpressions, partitioning.partitionKeys,
partitioning.isGrouped,
Review Comment:
Since `isCollapsed` is a constructor param, it participates in case-class
equality and survives canonicalization. Two otherwise-identical exchanges --
one templated from a collapsed partitioning, one from an equivalent
non-collapsed one (two independent joins over a shared subplan; the collection
OR-normalization doesn't reach across them) -- no longer compare equal under
`sameResult`, so `ReuseExchangeAndSubquery` shuffles the shared subplan twice
where it previously reused the exchange.
Narrow configuration, and arguably defensible since the metadata genuinely
differs (reusing a false-flag exchange where a true one was planned would
re-launder the protection under AQE re-planning) -- but it deserves a conscious
decision.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1445,7 +1513,13 @@ case class KeyedShuffleSpec(
te.copy(children = te.children.map(_ => clustering(positionSet.head)))
case (_, positionSet) => clustering(positionSet.head)
}
- KeyedPartitioning(newExpressions, partitioning.partitionKeys,
partitioning.isGrouped)
+ // The shuffled side is laid out on this side's partition keys, so it
inherits the flag.
+ // Strictly nothing collapsed on this side -- its partitions are what a
hash partitioning would
+ // give -- so this is deliberate conservatism: the two sides are
co-located on one key set, and
+ // a later grouping of that key set carries the collapsed side's risk. It
can only add shuffles,
+ // never remove one.
+ KeyedPartitioning(newExpressions, partitioning.partitionKeys,
partitioning.isGrouped,
+ partitioning.isCollapsed)
Review Comment:
The inherited flag is also stamped on join types that expose only the
shuffled side (`LeftOuter`/`LeftSemi`/`LeftAnti`/`LeftExistence` in
`ShuffledJoin.outputPartitioning`); for semi/anti the output carries zero
collapsed-side rows, yet inherits the flag forever. The created KP is grouped
(`canCreatePartitioning` requires `isGrouped`), so the flag only bites after a
union reintroduces duplicate keys -- exactly the case the class doc blesses as
groupable without opt-in -- and the flag's documented contract is false for
these partitionings.
The spec can't know the consuming join type, so if this is worth tightening,
the place would be `ShuffledJoin.outputPartitioning` (clear the flag for
`LeftExistence`). Fine to keep as the documented safe-direction conservatism,
but worth noting the cost and the labeling mismatch.
##########
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:
This `inherited || projected distinct < source distinct` predicate is now
hand-rolled at three producer sites: here,
`KeyedPartitioning.createShuffleSpec`, and
`GroupPartitionsExec.outputPartitioning`, each with site-specific inputs and
caveats. Given the PR's own observation that one producer laundering the flag
is how the protection went missing, a shared helper on `KeyedPartitioning`, e.g.
```scala
def collapsedAfterProjection(projectedDistinctKeyCount: Int): Boolean =
isCollapsed || projectedDistinctKeyCount < distinctKeyCount
```
would keep the correctness rule in one place instead of three files that
must stay in sync.
##########
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:
Two O(n) distinct passes over the same list: `projectedKeys.distinct.length`
here, then `.toGrouped` on the next statement re-runs
`partitionKeys.distinct.sorted` over the same `projectedKeys`; for an
ungrouped, uncollapsed source, forcing `distinctKeyCount` adds a third pass
over the source keys. The block also runs when `joinKeyPositions` selects every
position, where the comparison is tautologically false.
Materializing `val d = projectedKeys.distinct` once and building the grouped
KP directly (same ordering source as `toGrouped`), and/or guarding on positions
actually dropped, is semantically identical and cheaper. Planning-time only, so
minor -- but it runs per `createShuffleSpec` call from
EnsureRequirements/ValidateRequirements.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -528,22 +554,25 @@ case class CoalescedNullAwareHashPartitioning(
* guaranteed after projection. May contain duplicates
when ungrouped.
* @param isGrouped Whether partition keys are unique (no duplicates).
Computed on first
* creation, then preserved through copy operations to avoid
recomputation.
- * @param isNarrowed Whether this partitioning was derived from a
finer-grained one by dropping key
- * positions (e.g. via
`PartitioningPreservingUnaryExecNode`). When true and the
- * keys are no longer unique, `GroupPartitionsExec` may
merge partitions that held
- * distinct keys in the original partitioning, carrying the
same skew risk as
- * `allowKeysSubsetOfPartitionKeys`. "May", because the
condition is a proxy: the
- * duplicate keys can also come from a source that reports
several splits per
- * partition key, in which case grouping merges only
same-key partitions. Such a
- * partitioning can only satisfy `ClusteredDistribution` by
being grouped, and
- * `groupedSatisfies` refuses that unless the config is
enabled, regardless of
- * `requireAllClusterKeysForDistribution`.
+ * @param isCollapsed Whether a projection or a reduction mapped keys that
were distinct in the
+ * partitioning this one was derived from onto the same
key, so one partition
+ * here can stand for several of the original ones -- see
"Key Collapse" above.
+ * Dropping key positions does not set it on its own; the
projected keys have to
+ * actually lose distinctness. Sticky, because neither
grouping nor a further
+ * projection can make a partitioning finer again. One case
sets it without a
+ * collapse of its own: the side shuffled onto a collapsed
partitioning's keys
+ * inherits it, because the two are then co-located on that
key set -- see
+ * `KeyedShuffleSpec.createPartitioning`.
+ * Together with `!isGrouped` it decides whether
`groupedSatisfies` may coalesce
+ * the duplicate keys without
`allowKeysSubsetOfPartitionKeys`: `isCollapsed`
+ * says the collapse happened, `!isGrouped` says there is
still something left
+ * to merge, and only both together mean there is an
outstanding risk to gate.
*/
case class KeyedPartitioning(
expressions: Seq[Expression],
@transient partitionKeys: Seq[InternalRowComparableWrapper],
isGrouped: Boolean,
- isNarrowed: Boolean = false) extends Expression with Partitioning with
Unevaluable {
+ isCollapsed: Boolean = false) extends Expression with Partitioning with
Unevaluable {
Review Comment:
Keeping `isCollapsed: Boolean = false` as a defaulted parameter leaves the
laundering vector this PR closes open for the next producer: a future operator
that derives a KeyedPartitioning and calls the 3-arg constructor silently gets
`false` and bypasses the gate with no compiler or test signal -- the same
failure mode as SPARK-59026, and the wrong code looks identical to correct
pre-existing code.
Only the companion `apply` (a provably-fresh source partitioning) actually
relies on the default; dropping the default and passing `isCollapsed = false`
explicitly there would make the compiler force every future producer to decide
the flag.
--
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]