dongjoon-hyun commented on code in PR #59863:
URL: https://github.com/apache/spark/pull/59863#discussion_r4425921038
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -924,15 +928,27 @@ case class EnsureRequirements(
// them. Each side stamps why it is left ungrouped (`UngroupingOrigin`),
so a reader of the
// finished plan judges the pair on the layouts it holds; the stamp also
decides the node's
// routing (`GroupPartitionsExec.distributePartitions`).
- (
- GroupPartitionsExec(rawLeft, leftSpec.joinKeyPositions,
- Some(mergedPartitionKeys), leftReducers,
+ def regroupOrIdentity(
+ child: SparkPlan,
+ spec: KeyedShuffleSpec,
+ reducers: Option[Seq[Option[KeyReducer]]],
+ replicates: Boolean): SparkPlan = {
+ val gpe = GroupPartitionsExec(child, spec.joinKeyPositions,
+ Some(mergedPartitionKeys), reducers,
ungroupingOrigin = Option.when(applyPartialClustering)(
- if (replicateLeftSide) REPLICATED_FOR_JOIN else SPLIT_FOR_JOIN)),
- GroupPartitionsExec(rawRight, rightSpec.joinKeyPositions,
- Some(mergedPartitionKeys), rightReducers,
- ungroupingOrigin = Option.when(applyPartialClustering)(
- if (replicateRightSide) REPLICATED_FOR_JOIN else SPLIT_FOR_JOIN)))
+ if (replicates) REPLICATED_FOR_JOIN else SPLIT_FOR_JOIN))
+ // Keep both alignment nodes when a replicate side is chosen, even if
the join type
+ // declines it: a re-run peels a local sort only through a
`GroupPartitionsExec`, and
+ // that peel is how the replicate-side choice is reproduced.
+ if (!replicateSideChosen &&
+ gpe.outputPartitioning == child.outputPartitioning) {
+ child
Review Comment:
**[1] Returning the bare child also drops the node's guard against a child
rebuilt after `EnsureRequirements`.**
A `GroupPartitionsExec` reports `UnknownPartitioning` once its child no
longer reports the partitioning it was planned over, and
`checkChildStillMatches` then fails the query at execution
(GroupPartitionsExec.scala:137-142 and 353-358). Its doc names the rules that
rebuild such a child: `PlanAdaptiveDynamicPruningFilters` and
`ReuseAdaptiveSubquery` with AQE, and `ReuseExchangeAndSubquery` without it,
which rebuild a `BatchScanExec` when they rewrite a subquery in its runtime or
storage filters (GroupPartitionsExec.scala:126-131). The rebuilt scan plans its
input partitions again, since `batch` and `inputPartitions` are lazy vals
(BatchScanExec.scala:70 and 111).
With the identity node, a different split set from the connector on that
second call fails with an internal error. Without it, the join zips the rebuilt
scan with the other side's node, which was aligned to the old keys. That fails
with "Can't zip RDDs with unequal numbers of partitions" when the counts
differ, and silently pairs the wrong partitions when they match.
As that doc says, no rule is known to change the splits today, so this is
defense in depth. Could the comment at L918-920 say that a skipped side gives
up this guard, or could the node stay, as in [2]?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -726,11 +726,17 @@ class KeyGroupedPartitioningSuite
assert(collectAllShuffles(joins.head).isEmpty,
"should not add shuffle for both sides of the join")
val sides = Seq(joins.head.left,
joins.head.right).map(collectAllGroupPartitions)
- val distributed = sides.map(_.map(_.distributePartitions))
- assert(distributed == Seq(Seq(expectedLeftDistributed), Seq(false)),
+ // A side without a node distributes nothing either: its node would
have been a
+ // pass-through and is not built, and the side reports the layout it
already had.
Review Comment:
**[5] `checkPartiallyClusteredJoin` accepts a missing node in both arms, but
with partial clustering on, both sides always keep one.**
With the config on, `pushCommonValues` is always true
(EnsureRequirements.scala:732-735), and both join types here may duplicate the
right side (`ShuffledJoin.canDuplicateRightSide`), so a replicate side is
always chosen and EnsureRequirements.scala:943 keeps both nodes. That includes
the two calls where the left side is picked and the join type declines it. So
the comment at L729-730 holds only for the `partiallyClustered = false` arm. In
the other arm, a skipped identity node would still pass: it reads as not
distributing, and the partition count falls back to the side's own (L735-739),
which is the same for an identity node. Could that arm require both nodes?
```suggestion
// With partial clustering on, both join types here may duplicate
the right side, so a
// replicate side is chosen and both sides keep a node. With it off,
a side without a node
// distributes nothing: its node would have been a pass-through and
is not built.
if (partiallyClustered) {
assert(sides.forall(_.nonEmpty), s"both sides keep a grouping
node:\n$plan")
}
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala:
##########
@@ -2613,23 +2614,34 @@ class EnsureRequirementsSuite extends
SharedSparkSession {
// already wrapped the non-grouped side, and picked a different side
wherever one holds more
// than one split per key. Both shapes read the choice back off the
`distributePartitions`
// flags.
- def distribute(child: SparkPlan): Boolean = child.collectFirst {
- case g: GroupPartitionsExec => g
- }.get.distributePartitions
-
- def flags(smj: SparkPlan): (Boolean, Boolean) =
+ // A side picked to spread keeps its node; a side whose alignment is the
identity keeps its
+ // child and so has none, which reads as "not distributing". The node is
asserted on the side
+ // that is expected to distribute before the flags are read, so a node
that went missing
+ // cannot pass for the replicated side.
+ def flags(smj: SparkPlan, expected: (Boolean, Boolean)): Unit =
withSQLConf(
SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key ->
"true") {
val Seq(newLeft, newRight) = EnsureRequirements.apply(smj).children
- (distribute(newLeft), distribute(newRight))
+ val nodes = Seq(newLeft, newRight).map(_.collectFirst {
+ case g: GroupPartitionsExec => g
+ })
+ nodes.zip(Seq(expected._1, expected._2)).foreach { case (node,
distributes) =>
+ if (distributes) {
+ assert(node.isDefined, "a side that distributes keeps its grouping
node")
+ }
+ }
+ val distributed = nodes.map(_.exists(_.distributePartitions))
Review Comment:
**[3] This accepts a missing node on the side that is expected not to
distribute, although every case in this test now keeps both nodes.**
All four cases are inner joins with partial clustering on. An inner join may
duplicate either side, so a replicate side is always chosen, and
EnsureRequirements.scala:943 keeps both nodes, an identity one included. So the
comment at L2617-2620, which dates from the first commit, no longer holds here.
Because of the `exists` at L2634, the test also does not pin that gate. In the
last case (left `[1, 2]`, right `[1, 2, 3]`), the inner join intersects the
keys onto `[1, 2]`, so the replicated left node is the identity. Without the
`replicateSideChosen` term, as in the first commit, that node would be skipped,
read as "not distributing", and match the expected `false`.
Master's `distribute` read the node with `.get`. Could both nodes be
required again, and the comment at L2617-2620 say that every case here chooses
a replicate side?
```suggestion
assert(nodes.forall(_.isDefined), "a chosen replicate side keeps
both grouping nodes")
val distributed = nodes.map(_.get.distributePartitions)
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -913,7 +915,9 @@ case class EnsureRequirements(
}
}
- // Now we need to push-down the common partition information to the
`GroupPartitionsExec`s.
+ // Now we need to push-down the common partition information to the
sides that need it. A
+ // side whose alignment is the identity keeps its child instead (see
`regroupOrIdentity`
+ // below), which is common when one side was shuffled onto the other
side's layout.
Review Comment:
**[6] With this, the keyed-shuffle side often has no `GroupPartitionsExec`,
so the `outputPartitioning` doc in GroupPartitionsExec.scala:116-119 no longer
names what protects it.**
That doc says that an `AQEShuffleReadExec` landing over a keyed shuffle
stage relies on the node's fallback to `UnknownPartitioning`, which makes
`ValidateRequirements` reject the rewrite. Without the node, the rejection
comes from `AQEShuffleReadExec`'s own `UnknownPartitioning` for a coalesced or
skew-split read of a keyed layout (AQEShuffleReadExec.scala:102-110).
GroupPartitionsExec.scala is outside this diff, so I left this here. Nit: could
that paragraph say that a side without a node relies on `AQEShuffleReadExec`
instead?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala:
##########
@@ -2688,9 +2702,15 @@ class EnsureRequirementsSuite extends SharedSparkSession
{
SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key ->
"true") {
EnsureRequirements.apply(smj).children.map { child =>
- val gpe = child.collectFirst { case g: GroupPartitionsExec => g }.get
- (gpe.ungroupingOrigin,
- gpe.outputPartitioning.asInstanceOf[KeyedPartitioning].isGrouped)
+ child.collectFirst { case g: GroupPartitionsExec => g }.map { gpe =>
+ (gpe.ungroupingOrigin,
+ gpe.outputPartitioning.asInstanceOf[KeyedPartitioning].isGrouped)
+ }.getOrElse {
Review Comment:
**[4] With the `getOrElse` fallback, the mirrored LEFT OUTER case passes
whether its identity node is kept or skipped, so this test does not pin
374a7c3's keep for a declined choice.**
In `smj(LeftOuter, Seq(InternalRow(1)), threeSplits)` at L2734, the counts
pick the left side, which a left outer join may not duplicate. Since 374a7c3,
that declined choice keeps both nodes. With 94d5b1a's gate on
`applyPartialClustering`, the left identity node would be skipped, and this
fallback would return `(None, true)`, the same pair as the kept node, so the
assertion holds either way. Only the FULL OUTER case at L2738 needs the
fallback: neither side may be duplicated there, so no replicate side is chosen,
and the identity right side has no node.
Could the fallback apply to that case only, so that the other three calls
require a node on both sides? For example, `stampsOf(smj: SparkPlan,
nodeOnBothSides: Boolean = true)` with `assert(!nodeOnBothSides, ...)` at the
top of the `getOrElse` branch, and `nodeOnBothSides = false` on the FULL OUTER
call.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -924,15 +928,27 @@ case class EnsureRequirements(
// them. Each side stamps why it is left ungrouped (`UngroupingOrigin`),
so a reader of the
// finished plan judges the pair on the layouts it holds; the stamp also
decides the node's
// routing (`GroupPartitionsExec.distributePartitions`).
- (
- GroupPartitionsExec(rawLeft, leftSpec.joinKeyPositions,
- Some(mergedPartitionKeys), leftReducers,
+ def regroupOrIdentity(
+ child: SparkPlan,
+ spec: KeyedShuffleSpec,
+ reducers: Option[Seq[Option[KeyReducer]]],
+ replicates: Boolean): SparkPlan = {
+ val gpe = GroupPartitionsExec(child, spec.joinKeyPositions,
+ Some(mergedPartitionKeys), reducers,
ungroupingOrigin = Option.when(applyPartialClustering)(
- if (replicateLeftSide) REPLICATED_FOR_JOIN else SPLIT_FOR_JOIN)),
- GroupPartitionsExec(rawRight, rightSpec.joinKeyPositions,
- Some(mergedPartitionKeys), rightReducers,
- ungroupingOrigin = Option.when(applyPartialClustering)(
- if (replicateRightSide) REPLICATED_FOR_JOIN else SPLIT_FOR_JOIN)))
+ if (replicates) REPLICATED_FOR_JOIN else SPLIT_FOR_JOIN))
+ // Keep both alignment nodes when a replicate side is chosen, even if
the join type
+ // declines it: a re-run peels a local sort only through a
`GroupPartitionsExec`, and
+ // that peel is how the replicate-side choice is reproduced.
+ if (!replicateSideChosen &&
Review Comment:
**[2] Could the shuffled-hash-join conversion look through an identity
`GroupPartitionsExec`, instead of the alignment not building one?**
The conversion this PR enables is blocked in
`ConvertSortMergeJoinToShuffledHashJoin.findShuffleStage`
(ConvertSortMergeJoinToShuffledHashJoin.scala:187-210), which looks through the
join's own `SortExec`, and through a few row-wise operators behind a config,
but not through a `GroupPartitionsExec`. Removing the node where it is built
has costs elsewhere:
- A re-run peels a local sort only through a `GroupPartitionsExec`
(L940-942). That is what `replicateSideChosen` works around, and what
[r4404496222](https://github.com/apache/spark/pull/59863#discussion_r4404496222)
and
[r4411054861](https://github.com/apache/spark/pull/59863#discussion_r4411054861)
went through.
- The plan changes against 4.2.0, which builds the node on both sides
(`EnsureRequirements.scala:683-687` at `v4.2.0`).
- The skipped side loses the guard in [1] and the node's metrics.
A look-through next to the `SortExec` case
(ConvertSortMergeJoinToShuffledHashJoin.scala:193) would keep the plan as it is:
```scala
// An identity regrouping reads each stage partition exactly once, so
the stage's
// statistics still bound every build partition.
case g: GroupPartitionsExec if g.grouping.isIdentity =>
findShuffleStage(g.child, lookThroughOperatorsEnabled)
```
`PartitionGrouping.isIdentity` (GroupPartitionsExec.scala:837-841) holds
only when output partition i is input partition i and no key was rewritten,
which rules out coalescing, padding, pruning and replication, so `mapStats`
stays a per-partition bound. `stripSort`
(ConvertSortMergeJoinToShuffledHashJoin.scala:163-166) then leaves the node
under the shuffled hash join, and the `EnsureRequirements` re-run after the
conversion (ConvertSortMergeJoinToShuffledHashJoin.scala:155) finds the node to
peel. Could you consider this, or say in the description why removing the node
is preferred?
--
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]