ulysses-you commented on code in PR #58659:
URL: https://github.com/apache/spark/pull/58659#discussion_r4012377678
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -64,70 +72,15 @@ import org.apache.spark.sql.vectorized.ColumnarBatch
*/
case class GroupPartitionsExec(
child: SparkPlan,
- @transient joinKeyPositions: Option[Seq[Int]] = None,
- @transient expectedPartitionKeys:
Option[Seq[(InternalRowComparableWrapper, Int)]] = None,
- @transient reducers: Option[Seq[Option[KeyReducer]]] = None,
- @transient distributePartitions: Boolean = false,
- @transient enableSortedMerge: Boolean = false
+ @transient grouping: PartitionGrouping,
Review Comment:
grouping and outputPartitioning are carried over a child rewrite (:302-303),
and the factory doc (:404-415) states the invariant that nothing in the tree
hands this node a child reporting a different partitioning. I verified it for
the codegen and columnar wrappers (ColumnarToRowExec, RowToColumnarExec,
InputAdapter, WholeStageCodegenExec all report child.outputPartitioning), but
not for AQE, and I could not settle it by reading.
A probe at this head shows what the carried fields do once the invariant is
broken:
- withNewChildren(Seq(childReportingOnePartition)) still reports
numPartitions 2 and groups the two old input partitions, silently, while
child.outputPartitioning reports 1.
- A Java serialization round trip leaves grouping == null (NPE on
groupedPartitions) and an outputPartitioning whose KeyLayout.partitionKeys is
null (KeyLayout:493 is @transient) while numPartitions still reports the old
count, so toString and equals change across the wire.
The base held all three of these as @transient ... lazy val (base :77, :226,
:309), so a rewritten or deserialized node recomputed them from its new child
and could not be stale.
The rewriter I could not rule out is an AQEShuffleReadExec landing between
this node and a keyed shuffle child: CoalesceShufflePartitions.isSupported
accepts a keyed ENSURE_REQUIREMENTS shuffle (CoalesceShufflePartitions:38-44),
and AQEShuffleReadExec reports UnknownPartitioning for a keyed shuffle
(AQEShuffleReadExec:100-105). With the stale keyed claim the join above still
satisfies its distribution, so ValidateRequirements.validate
(AdaptiveSparkPlanExec:205-213) would accept the coalesced plan, where the base
rejects it and reverts the rewrite.
Since I could not build a plan with a GroupPartitionsExec directly over a
keyed ShuffleExchangeExec, I am not calling this blocking. Could we either
re-derive in withNewChildInternal when newChild.outputPartitioning ne
child.outputPartitioning, or name in the doc the rules that guarantee the
invariant? Either way a future child-rewriting rule fails loudly instead of
reading a layout its child no longer has.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -599,7 +700,16 @@ case class EnsureRequirements(
(!compatibleAsIs ||
conf.v2BucketingPartiallyClusteredDistributionEnabled) &&
(conf.v2BucketingPushPartValuesEnabled ||
conf.v2BucketingAllowKeysSubsetOfPartitionKeys)
- if (pushCommonValues) {
+ // Neither route lines the two sides up, so there is no pairing to commit
to. Asked here rather
+ // than after the push branch, which is the only thing below that does any
work.
+ if (!compatibleAsIs && !pushCommonValues) {
+ return None
+ }
+ // What the push branch builds, when it runs. Empty otherwise, and then
each side gets a plain
+ // grouping node instead, which it needs exactly when its source reports
more than one partition
+ // per key. Building both eagerly would derive a grouping the push branch
throws away, and that
+ // is one hash per partition key.
+ val pushed = if (pushCommonValues) {
logInfo("Pushing common partition values for storage-partitioned join")
Review Comment:
This log fires inside `pushed`, but the pairing can still be discarded by
the describesSameKeys gate at :915, which cannot be asked earlier because it
needs the built nodes. So the log can announce a pushdown that never lands. The
bestPair.isEmpty early return at :689 avoids exactly this for the other
discarding case, so this reads as a miss. Moving the log after the gate would
keep the messages truthful, and this line is how a reader explains the extra
shuffle in the SPARK-59050 query.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -788,152 +882,193 @@ case class EnsureRequirements(
}
// Now we need to push-down the common partition information to the
`GroupPartitionsExec`s.
- newLeft = applyGroupPartitions(left, leftSpec.joinKeyPositions,
mergedPartitionKeys,
- leftReducers, distributePartitions = applyPartialClustering &&
!replicateLeftSide)
- newRight = applyGroupPartitions(right, rightSpec.joinKeyPositions,
mergedPartitionKeys,
- rightReducers, distributePartitions = applyPartialClustering &&
!replicateRightSide)
+ Some((
+ GroupPartitionsExec(rawLeft, leftSpec.joinKeyPositions,
+ Some(mergedPartitionKeys), leftReducers,
+ distributePartitions = applyPartialClustering && !replicateLeftSide),
+ GroupPartitionsExec(rawRight, rightSpec.joinKeyPositions,
+ Some(mergedPartitionKeys), rightReducers,
+ distributePartitions = applyPartialClustering &&
!replicateRightSide)))
+ } else {
+ None
}
- if (compatibleAsIs || pushCommonValues) Some(Seq(newLeft, newRight)) else
None
+ // The pairing is only worth committing to if both children still declare
the same aligned key
+ // sequence once they are built. They can fail that. A
`GroupPartitionsExec` gives up its keyed
+ // claim when it turns out to regroup a layout that pins undeclared rows to
+ // `hash(key) % numPartitions` (see
`KeyLayout.mayContainUnknownPartitionKeys`), and
+ // only the node knows the permutation it performs, so that answer arrives
after the pairing
+ // was chosen. Asking before returning is what keeps the join from
skipping both shuffles for a
+ // child that no longer satisfies its distribution, which is a plan
`ValidateRequirements`
+ // rejects and every AQE rule that needs a valid plan then refuses to
touch.
+ //
+ // The check is pairwise, not a per-side `satisfies`. Partially clustered
distribution leaves
+ // both children value-aligned yet not grouped on purpose, so a per-side
gate would refuse that
+ // whole family. What both sides owe each other is the key sequence
`alignToExpectedKeys`
+ // guarantees, each key repeated as many times as the merge expects,
whichever side replicates.
+ // Through `KeyLayout.describesSameKeys`, which carries the reason the key
types are compared
+ // as well as the rows.
+ def declaredLayout(plan: SparkPlan): Option[KeyLayout] =
+
PartitioningCollection.representativeOf(plan.outputPartitioning).map(_.layout)
+ val (newLeft, newRight) =
+ pushed.getOrElse((groupIfNeeded(rawLeft), groupIfNeeded(rawRight)))
+ Option.when(declaredLayout(newLeft).exists { left =>
+ declaredLayout(newRight).exists(left.describesSameKeys)
+ })(Seq(newLeft, newRight))
+ }
+
+ /**
+ * Whether the two children satisfy their distributions and line up with
each other as they
+ * arrive, so that the pairing has nothing to add. This is the question
`ValidateRequirements`
+ * asks of a finished plan, restricted to these two children.
+ *
+ * Both sides have to answer with an unprojected spec. A projected one
describes the layout a
+ * [[GroupPartitionsExec]] would emit rather than the one the child has, so
two sides can agree
+ * through their projections while their partitions do not line up at all.
+ */
+ private def alreadyCoPartitioned(
+ left: SparkPlan,
+ leftRequired: ClusteredDistribution,
+ right: SparkPlan,
+ rightRequired: ClusteredDistribution): Boolean = {
+ def unprojectedSpecs(plan: SparkPlan, required: ClusteredDistribution):
Seq[KeyedShuffleSpec] =
+ if (plan.outputPartitioning.satisfies(required)) {
+ candidatesFor(plan, required).filter(_.joinKeyPositions.isEmpty)
+ } else {
+ Nil
+ }
+ val leftSpecs = unprojectedSpecs(left, leftRequired)
+ val rightSpecs = unprojectedSpecs(right, rightRequired)
+ leftSpecs.exists(l => rightSpecs.exists(l.isCompatibleWith))
}
private def checkShufflePartitionIdPassThroughCompatible(
left: SparkPlan,
+ leftRequired: ClusteredDistribution,
right: SparkPlan,
- requiredChildDistribution: Seq[Distribution]): Boolean = {
+ rightRequired: ClusteredDistribution): Boolean = {
(left.outputPartitioning, right.outputPartitioning) match {
case (p1: ShufflePartitionIdPassThrough, p2:
ShufflePartitionIdPassThrough) =>
- assert(requiredChildDistribution.length == 2)
- val leftSpec = p1.createShuffleSpec(
- requiredChildDistribution.head.asInstanceOf[ClusteredDistribution])
- val rightSpec = p2.createShuffleSpec(
- requiredChildDistribution(1).asInstanceOf[ClusteredDistribution])
- leftSpec.isCompatibleWith(rightSpec)
+
p1.createShuffleSpec(leftRequired).isCompatibleWith(p2.createShuffleSpec(rightRequired))
case _ =>
false
}
}
/**
- * The innermost `GroupPartitionsExec` reachable from `plan` by descending
only through nodes
- * this rule itself inserted above it, together with a function rebuilding
the traversed local
- * sorts over a replacement node. `None` when no `GroupPartitionsExec` is
reachable.
+ * The plan a co-partitioned child's source reports, with every grouping and
local sort this rule
+ * put over it peeled off. `plan` itself when it carries none.
+ *
+ * The grouping peeled here is one an earlier pass left behind, not one this
pass put on. The
+ * per-child step skips co-partitioned children entirely, so nothing of this
pass's is under
+ * there when `checkKeyGroupCompatible` asks. The shuffle step, the other
caller, can see both.
+ * `EnsureRequirements` is re-run on plans it already produced, since
`AdaptiveSparkPlanExec`
+ * builds one instance of this rule, and
`ConvertSortMergeJoinToShuffledHashJoin` and
+ * `OptimizeSkewedJoin` hand the whole tree back to it after rewriting some
other join, all within
+ * one `queryStagePreparationRules` pass. So a join child can arrive as
+ * `GroupPartitionsExec(SortExec(GroupPartitionsExec(scan)))`, and planning
from anything but the
+ * scan would derive the alignment from an already aligned layout and
duplicate rows.
*
* The descent only traverses a `GroupPartitionsExec` and a *local*
`SortExec`. That bound is a
* decision, not an omission: a `GroupPartitionsExec` hidden behind any
other node belongs to a
- * different operator, and reusing it would move that operator's alignment.
Instrumentation of
+ * different operator, and peeling it would undo that operator's alignment.
Instrumentation of
* the descent over `KeyGroupedPartitioningSuite` found these non-`SortExec`
shapes hiding a
* node: `Project > SortMergeJoin > Sort > GroupPartitions` and `Project >
Filter > Window >
* WindowGroupLimit > GroupPartitions`, where refusing to descend is right
every time. A global
* `SortExec` also stops the descent: it requires `OrderedDistribution`,
which a
* `KeyedPartitioning` can satisfy (behind
`spark.sql.sources.v2.bucketing.sorting.enabled`)
- * through a `GroupPartitionsExec` built to emit the partition keys in
sorted order, and
- * reusing that node for a join would destroy the ordering it exists to
provide.
- */
- private def innermostGroupPartition(
- plan: SparkPlan): Option[(GroupPartitionsExec, SparkPlan => SparkPlan)]
= plan match {
- case g: GroupPartitionsExec =>
- // When groupings stack, the outer one is the wrap this invocation's
distribution step
- // just added; the one below is inherited from an earlier pass and owns
the alignment to
- // preserve. Keep the descent below the outer node and drop it.
- innermostGroupPartition(g.child).orElse(Some((g, identity[SparkPlan])))
- case s: SortExec if !s.global =>
- innermostGroupPartition(s.child).map { case (g, rebuild) =>
- (g, (newChild: SparkPlan) => s.withNewChildren(Seq(rebuild(newChild))))
- }
- case _ => None
- }
-
- /**
- * Rewrites the innermost `GroupPartitionsExec` in `plan` with `f` and drops
any redundant
- * grouping stacked above it, per the descent of
[[innermostGroupPartition]]. Returns `None`
- * when `plan` holds no `GroupPartitionsExec`, leaving it to the caller to
create one.
- *
- * This is what makes the rule idempotent for storage-partitioned joins.
`EnsureRequirements`
- * is re-run on plans it already produced: `AdaptiveSparkPlanExec` builds
one instance of this
- * rule, and `ConvertSortMergeJoinToShuffledHashJoin` and
`OptimizeSkewedJoin` hand the whole
- * tree back to it after rewriting some other join, all within one
- * `queryStagePreparationRules` pass. A join child then arrives as
- * `SortExec(GroupPartitionsExec(...))` rather than a bare scan, and the
distribution step adds
- * a plain `GroupPartitionsExec` on top, because a partially clustered
`KeyedPartitioning`
- * reports `isGrouped = false` by design and so is only satisfied "after
grouping". Rewriting
- * that outer node instead of the one below it re-derives the alignment from
an already-aligned
- * layout and duplicates rows; descending to the innermost node and dropping
what sits above it
- * reproduces the plan a single pass would have produced.
+ * through a `GroupPartitionsExec` built to emit the partition keys in
sorted order, and peeling
+ * that node would destroy the ordering it exists to provide.
*
- * Dropping a grouping is safe because only `applyGroupPartitions` calls
this, reached from
- * `checkKeyGroupCompatible`, which runs for joins alone: every
`GroupPartitionsExec` a join
- * child carries is this rule's own. A single-child operator genuinely needs
its non-grouped
- * input grouped and takes the wrap in the children loop instead;
`withJoinKeyPositions`, which
- * other multi-child operators reach, does not reuse at depth.
- */
- private[exchange] def rewriteGroupPartitions(plan: SparkPlan)(
- f: GroupPartitionsExec => GroupPartitionsExec): Option[SparkPlan] =
- innermostGroupPartition(plan).map { case (g, rebuild) =>
- val rewritten = f(g)
- rewritten.copyTagsFrom(g)
- rebuild(rewritten)
- }
-
- /**
- * Unwraps the groupings and local sorts this rule inserted over a child,
down to the
- * pre-alignment plan, per the descent of [[innermostGroupPartition]].
Peeling one level stops
- * at the local sort this rule added, leaving the earlier pass's alignment
in place.
- */
- private def unwrapGroupPartitions(plan: SparkPlan): SparkPlan =
- innermostGroupPartition(plan).map(_._1.child).getOrElse(plan)
-
- /**
- * Applies or updates `GroupPartitionsExec` with the given parameters.
+ * A local sort that is peeled off is re-added by the ordering step at the
end of
+ * `ensureDistributionAndOrdering`, which is what put it there in the first
place.
*
- * Reuses the node this rule inserted over the join child in an earlier
pass, per the descent
- * of [[innermostGroupPartition]], and creates a new one when the child
carries none.
+ * The two callers are `checkKeyGroupCompatible` and the shuffle step, both
on the co-partitioned
+ * path, so every `GroupPartitionsExec` this reaches is one the rule put
there itself. A
+ * single-child operator genuinely needs its non-grouped input grouped and
keeps the wrap
+ * `resolveChild` gave it. `withJoinKeyPositions`, which other multi-child
operators reach, does
+ * not descend at all.
*/
- private def applyGroupPartitions(
- plan: SparkPlan,
- joinKeyPositions: Option[Seq[Int]],
- mergedPartitionKeys: Seq[(InternalRowComparableWrapper, Int)],
- reducers: Option[Seq[Option[KeyReducer]]],
- distributePartitions: Boolean): SparkPlan = {
- rewriteGroupPartitions(plan) { g =>
- g.copy(
- joinKeyPositions = g.joinKeyPositions.orElse(joinKeyPositions),
- expectedPartitionKeys = Some(mergedPartitionKeys),
- // Unlike `joinKeyPositions`, these need no `orElse`. A re-run with
reducers never reaches
- // here. Both sides then report the same reduced keys, so
`compatibleAsIs` holds and the
- // push branch is skipped.
- reducers = reducers,
- distributePartitions = distributePartitions)
- }.getOrElse {
- GroupPartitionsExec(plan, joinKeyPositions, Some(mergedPartitionKeys),
reducers,
- distributePartitions)
+ private[exchange] def peelGroupPartitions(plan: SparkPlan): SparkPlan = {
+ // `None` unless a `GroupPartitionsExec` is actually down there. A local
sort is only this
+ // rule's to drop when it sits over one, otherwise it is the user's
`sortWithinPartitions` and
+ // peeling it would lose an ordering nothing puts back.
+ def peel(p: SparkPlan): Option[SparkPlan] = p match {
+ case g: GroupPartitionsExec => Some(peel(g.child).getOrElse(g.child))
+ case s: SortExec if !s.global => peel(s.child)
+ case _ => None
}
+ peel(plan).getOrElse(plan)
}
/**
* Applies join key positions to a plan by wrapping or updating
GroupPartitionsExec.
*
- * Unlike `applyGroupPartitions`, this does not descend: it serves every
multi-child operator,
+ * Unlike `peelGroupPartitions`, this does not descend. It serves every
multi-child operator,
* not just joins, so a `GroupPartitionsExec` below the top is not known to
be this rule's own.
*/
private[exchange] def withJoinKeyPositions(plan: SparkPlan, positions:
Seq[Int]): SparkPlan = {
plan match {
case g: GroupPartitionsExec =>
- val newGroupPartitions = g.copy(joinKeyPositions = Some(positions))
+ // Rebuilt rather than copied: the positions are an input to the
node's grouping, and a
+ // `copy` would keep the grouping derived from the old ones.
Rebuilding from `g.child`
+ // discards whatever else `g` carried, and the assert says what that
may be.
+ //
+ // `positions` index the layout `g` reports, while `g.child` holds the
raw partition
+ // expressions, so they are composed rather than replaced. The two
index spaces differ
+ // whenever `g` already projects, which happens on two paths.
`resolveChild` projects a
+ // co-partitioned child onto its cluster keys before the pairing
declines, and the two
+ // derivations disagree about which positions those are.
`positionsCoveringClusterKeys`
+ // also keeps an expression that *is* a cluster key, where
`KeyedShuffleSpec.keyPositions`
+ // reads an expression's reference. And a re-run reads the positions
off a report an
+ // earlier pass already projected.
+ assert(g.expectedKeyCount.isEmpty && g.reducers.isEmpty &&
!g.distributePartitions,
+ "expected a grouping this rule inserted for a co-partitioned child")
+ val composed = g.joinKeyPositions.fold(positions)(positions.map(_))
Review Comment:
`positions.map(_)` is `positions.map(oldPositions)` with the Seq used as a
Function1 (SeqOps is a PartialFunction). Typer output: ((x$1: Seq[Int]) =>
positions.map[Int](x$1)). That is the composition the comment intends, but it
reads as positions.map(identity) and would stop compiling if either side became
an Array or a Set. positions.map(g.joinKeyPositions.get) says the same thing
explicitly.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala:
##########
@@ -2576,6 +2552,185 @@ class EnsureRequirementsSuite extends
SharedSparkSession {
s"EnsureRequirements must satisfy the required
distribution:\n${newChild.treeString}")
}
+ test("SPARK-59289: a grouped side is paired on its own key order, not a
sorted one") {
+ // Both sides are grouped, so neither gets a node to reorder anything, and
the left's keys are
+ // not sorted. Planning the pairing against `toGrouped`, which sorts,
would claim an order the
+ // left does not have: the two would look compatible, the left would keep
its own report and
+ // the right would keep the sorted one, and the join would carry two
`KeyedPartitioning`s over
+ // different keys. `toGrouped` is therefore only for a source that is not
grouped.
+ val unsorted = Seq(InternalRow(3), InternalRow(4), InternalRow(1),
InternalRow(2))
+ val sorted = Seq(InternalRow(1), InternalRow(2), InternalRow(3),
InternalRow(4))
+ val left = DummySparkPlan(outputPartitioning =
KeyedPartitioning(Seq(exprA), unsorted))
+ val right = DummySparkPlan(outputPartitioning =
KeyedPartitioning(Seq(exprB), sorted))
+ val smj = SortMergeJoinExec(exprA :: Nil, exprB :: Nil, Inner, None, left,
right)
+
+ withSQLConf(SQLConf.V2_BUCKETING_ENABLED.key -> "true") {
+ val planned = EnsureRequirements.apply(smj)
+ val declared = planned.children.map { child =>
+ child.outputPartitioning.asInstanceOf[Expression]
+ .collectFirst { case k: KeyedPartitioning => k.partitionKeys }
+ }
+ assert(declared.forall(_.isDefined), s"both sides stay
keyed:\n${planned.treeString}")
+ assert(declared.head == declared(1),
+ s"both sides must declare one key sequence:\n${planned.treeString}")
+ }
+ }
+
+ test("SPARK-59289: pushing join key positions into a node re-derives its
grouping") {
+ // The positions are an input to the node's grouping, so
`withJoinKeyPositions` rebuilds through
+ // the factory. A `copy` would keep the grouping the old positions
produced, and nothing about
+ // the node would look wrong.
+ val keys = Seq(InternalRow(1, 1), InternalRow(1, 2))
+ val leaf = DummySparkPlan(
+ outputPartitioning = KeyedPartitioning(Seq(exprA, exprB), keys))
+ val coalescing = GroupPartitionsExec(leaf)
+ assert(coalescing.outputPartitioning.numPartitions == 2, "test setup:
nothing merged yet")
+
+ val projected = EnsureRequirements.withJoinKeyPositions(coalescing, Seq(0))
+ assert(projected.outputPartitioning.numPartitions == 1,
+ "projecting [a, b] onto [a] merges the two partitions that share a = 1")
+ }
+
+ test("SPARK-59289: single-partition children still honour a required
partition count") {
+ // Both sides are already `SinglePartition` and small, so there is no
co-partitioning to
+ // arrange. Each child still has to satisfy its own requirement, and a
stateful operator's
+ // `ClusteredDistribution` carries a partition count that one partition
does not meet.
+ val logicalPlan = StatsTestPlan(Nil, 1L, AttributeMap.empty, Some(1L))
+ val left = DummySparkPlan(outputPartitioning = SinglePartition)
+ left.setLogicalLink(logicalPlan)
+ val right = DummySparkPlan(outputPartitioning = SinglePartition)
+ right.setLogicalLink(logicalPlan)
+ val parent = DummySparkPlan(
+ children = Seq(left, right),
+ requiredChildDistribution = Seq(
+ ClusteredDistribution(Seq(exprA), requiredNumPartitions = Some(5)),
+ ClusteredDistribution(Seq(exprC), requiredNumPartitions = Some(5))),
+ requiredChildOrdering = Seq(Nil, Nil))
+
+ val planned = EnsureRequirements.apply(parent)
+ assert(ValidateRequirements.validate(planned),
+ s"each child must satisfy its own distribution:\n${planned.treeString}")
+ }
+
+ test("SPARK-59289: a local sort with no grouping under it is left alone") {
+ // `peelGroupPartitions` peels the local sorts this rule added over a
grouping. A sort with no
+ // grouping beneath it is the user's `sortWithinPartitions`, and the
ordering step only puts
+ // back the ordering the operator required, so peeling it would lose that
ordering for good.
+ val keys = Seq(InternalRow(1), InternalRow(2))
+ val leaf = DummySparkPlan(outputPartitioning =
KeyedPartitioning(Seq(exprA), keys))
+ val userSort = SortExec(Seq(SortOrder(exprB, Ascending)), global = false,
leaf)
+ assert(EnsureRequirements.peelGroupPartitions(userSort) === userSort)
+
+ // Over a grouping it is this rule's own, and both come off.
+ val ruleSort = SortExec(Seq(SortOrder(exprB, Ascending)), global = false,
+ GroupPartitionsExec(leaf))
+ assert(EnsureRequirements.peelGroupPartitions(ruleSort) === leaf)
+ }
+
+ test("SPARK-59289: a partitioning is never projected onto no position") {
+ // The co-partitioned path takes its projection positions from
`KeyedShuffleSpec.keyPositions`,
+ // which maps an expression's single *reference* onto the clustering.
`keysSatisfy` also
+ // accepts an expression that *is* a cluster key, through
+ // `KeyedPartitioning.positionsCoveringClusterKeys`. This is the shape
where the two disagree:
+ // the join keys name `years(ts)`, so the spec is built, and then no
position covers a cluster
+ // key.
+ //
+ // Projecting onto no position coalesces every partition into one. Before
the guard in
+ // `createShuffleSpec` this planned as a storage-partitioned join over a
single partition per
+ // side, two `GroupPartitionsExec`s and no shuffle, which is correct and
ruinous. The spec now
+ // comes back unprojected, `areKeysCompatible` refuses it, and the join
takes the shuffle.
+ val exprTs = AttributeReference("ts", IntegerType)()
+ val transform = years(exprTs)
+ val keys = Seq(InternalRow(1), InternalRow(2))
+ val left = DummySparkPlan(outputPartitioning =
KeyedPartitioning(Seq(transform), keys))
+ val right = DummySparkPlan(outputPartitioning =
KeyedPartitioning(Seq(transform), keys))
+ val smj = SortMergeJoinExec(transform :: Nil, transform :: Nil, Inner,
None, left, right)
+ withSQLConf(
+ SQLConf.V2_BUCKETING_ENABLED.key -> "true",
+ SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true") {
+ val planned = EnsureRequirements.apply(smj)
+ assert(groupPartitionsNodes(planned).isEmpty,
+ "a grouping here could only be the one that collapses both sides onto
a single partition")
+ assert(planned.collect { case s: ShuffleExchangeExec => s }.size == 2,
+ "neither side can serve the distribution, so both are shuffled")
+ }
+ }
+
+ test("SPARK-59289: a second pass leaves a pairing this rule already made
alone") {
+ val aL = AttributeReference("aL", IntegerType)()
+ val bL = AttributeReference("bL", IntegerType)()
+ val bR = AttributeReference("bR", IntegerType)()
+ // The left is keyed on (aL, bL) and the join is on bL alone, so the
left's spec projects onto
+ // position 1, and the right, which reports nothing, is shuffled onto that
layout. The first
+ // pass therefore ends with a projecting grouping node over the left and a
keyed shuffle over
+ // the right. That is the shape the second pass used to re-pair: peeling
the node hands the
+ // pairing a raw left that projects again, so it stopped being
co-partitioned as it stands and
+ // both sides were aligned to a merged key set they already held.
+ val leftKeys = Seq(InternalRow(1, 10), InternalRow(2, 20))
+ val left = new DummySparkPlanWithBatchScanChild(
+ outputPartitioning = KeyedPartitioning(Seq(aL, bL), leftKeys))
+ val right = DummySparkPlan(outputPartitioning = UnknownPartitioning(3))
+
+ withSQLConf(
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true",
+ SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+ val smj = SortMergeJoinExec(Seq(bL), Seq(bR), Inner, None, left, right)
+ val planned = EnsureRequirements.apply(smj)
+ assert(groupPartitionsNodes(planned).map(_.joinKeyPositions) ===
Seq(Some(Seq(1))),
+ "test setup: the left keeps its own layout with a projection")
+ assert(planned.collect { case s: ShuffleExchangeExec => s }.size == 1,
+ "test setup: the right is shuffled onto it")
+
+ assert(EnsureRequirements.apply(planned) == planned,
Review Comment:
keepArrivedPairing only fires with partially clustered distribution off, and
the PR pins one shape. Since idempotency is now an explicit decision rather
than a property of the reuse-at-depth mechanism, could we get an assertion set
over a handful of SPJ queries, EnsureRequirements.apply(planned) == planned,
including a partially clustered one so the re-plan path runs? The 2713
applications / 0 differs number is a measurement, not a test.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -561,34 +352,289 @@ case class GroupPartitionsExec(
val joinKeyStr = joinKeyPositions.map { p =>
s"JoinKeyPositions: ${truncatedString(p, "[", ", ", "]",
joinKeyMaxFields)}"
}.iterator
- val expectedStr = expectedPartitionKeys.map(ks => s"ExpectedPartitionKeys:
${ks.size}")
+ val expectedStr = expectedKeyCount.map(n => s"ExpectedPartitionKeys: $n")
val reducersStr = reducers.map { seq =>
val names = seq.map(_.map(_.reducer.displayName()).getOrElse("identity"))
s"Reducers: ${truncatedString(names, "[", ", ", "]", joinKeyMaxFields)}"
}
val distributeStr = Iterator(s"DistributePartitions:
$distributePartitions")
- // Rendered from the constructor field, as `DistributePartitions` above
is. Not from
- // `usesSortedMerge`, because that forces `grouping`, which can throw, and
this method feeds
- // `simpleString`, which `treeString` calls on error paths.
+ // Rendered from the constructor field, as `DistributePartitions` above
is, and not from
+ // `usesSortedMerge`, which reads the child's ordering. This method feeds
`simpleString`, which
+ // `treeString` calls on error paths, so it stays on what the node was
built with.
val sortedMergeStr = Iterator(s"SortedMerge: $enableSortedMerge")
joinKeyStr ++ expectedStr ++ reducersStr ++ distributeStr ++ sortedMergeStr
}
}
/**
- * 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. Every
- * member is given the same `layout` instance, which is what
- * `PartitioningCollection.fromPartitionings` needs to return them untouched.
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.
+ * What a [[GroupPartitionsExec]] does to its child's partitions.
+ *
+ * @param partitions the index groups the node emits, each with the key it
stands for
+ * @param layout what the node reports about those groups. Every member of a
+ * [[PartitioningCollection]] is given this same instance, which
is what
+ * `PartitioningCollection.fromPartitionings` needs to return
them untouched.
+ * @param isIdentity whether the grouping leaves the declared keys and every
partition where they
+ * were. Nothing rewrote the keys, output partition i holds
exactly input
+ * partition i, and there is one output per input. That is
the only grouping that
+ * keeps a marked layout's undeclared rows at hash(key) %
numPartitions. A
+ * projection or reduction re-labels the groups into a
different key space, so
+ * even a grouping whose indices line up would pin the claim
to keys it no longer
+ * declares. A rewrite is rejected up front, covering a
narrowing
+ * projection, a reordering one, and any reducer slot. A
reducer slot counts as
+ * key-changing even though a conforming self-reducer cannot
rewrite a reachable
+ * key value, so the give-up there loses at most an
optimization. A grouping that
+ * drops trailing declared keys reads identity for every
group it keeps, but the
+ * partition count shrinks and the hash modulus with it.
+ * @param numPrunedPartitions the alignment's effect on the reads of the
child's splits, 0 outside
+ * the alignment path. See `alignToExpectedKeys`.
+ * @param numReplicatedPartitionReads as above.
*/
-private case class PartitionGrouping(
+private[sql] case class PartitionGrouping(
partitions: Seq[(InternalRowComparableWrapper, Seq[Int])],
layout: KeyLayout,
- keysRewritten: Boolean,
+ isIdentity: Boolean,
numPrunedPartitions: Int,
- numReplicatedPartitionReads: Int)
+ numReplicatedPartitionReads: Int) {
+
+ def isGrouped: Boolean = layout.isGrouped
+}
+
+private[sql] object GroupPartitionsExec {
+
+ /**
+ * Builds a node over `child`, deriving `grouping` and `outputPartitioning`
from the parameters.
+ *
+ * **Both are derived, and neither `copy` nor the generated `apply`
re-derives them**, so a change
+ * to `child`, `joinKeyPositions`, `expectedPartitionKeys`, `reducers` or
`distributePartitions`
Review Comment:
The doc lists expectedPartitionKeys among the parameters that have to come
back through the factory, but that name only exists on apply; the field it
feeds is expectedKeyCount (:78). One word so a reader can find the field.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1252,7 +1336,26 @@ case class PartitioningCollection(partitionings:
Seq[Partitioning])
partitionings.exists(_.satisfies(required))
override def createShuffleSpec(distribution: ClusteredDistribution):
ShuffleSpec = {
- val filtered = partitionings.filter(_.satisfies(distribution))
+ // `maySatisfyAfterGrouping`, not `satisfies`. A spec says what its
partitioning could
+ // co-partition on, and a `KeyedPartitioning` that needs a
`GroupPartitionsExec` first still
+ // can. The strict question would drop a member whose keys are coarser
than the operation's,
+ // which is narrower than what this filter admitted before `satisfies`
became strict.
+ //
+ // `ValidateRequirements` builds a spec from a finished plan through here
too, and this stays
+ // sound for it. A member that is not grouped reports its own ungrouped
keys, duplicates and
+ // all, so it can only pair with a side holding the same sequence, which
is co-partitioned.
+ // `KeyedShuffleSpec.canCreatePartitioning` gates on `isGrouped`
separately, so such a member
+ // is never a shuffle template either.
+ //
+ // Every admitted member has to stay, because `isCompatibleWith` answers
for any of them and
+ // the collection cannot know which one the other side matched. That has a
cost worth knowing:
+ // `KeyedShuffleSpec.canCreatePartitioning` is false without
`v2BucketingShuffleEnabled` and
+ // `ShuffleSpecCollection.canCreatePartitioning` is a `forall`, so a
groupable keyed member
+ // beside a usable non-keyed one would cost the collection its role as a
shuffle template. No
+ // operator is known to report that mixture, since `EnsureRequirements`
groups a keyed child
+ // before it can reach a join's output.
+ val filtered =
Review Comment:
This filter also feeds ValidateRequirements (ValidateRequirements:57-68),
which is the gate that makes AQE drop a rewrite that breaks co-partitioning
(AdaptiveSparkPlanExec:209), and it now admits members whose spec describes a
layout the child does not have.
The soundness argument in the comment holds for two grouped sides. For an
ungrouped side a duplicated key row means the group is split across the
partitions carrying that key, and two such sides are co-partitioned only when
their splits line up (partial clustering makes them line up by construction; an
ordinary ungrouped source does not).
I could not find a producer of a collection holding an ungrouped keyed
member next to a satisfying member: scans report a bare KeyedPartitioning
(DataSourceV2ScanExecBase:120-134), and checkKeyedPartitioningInvariant forces
all keyed members of a collection to share one layout. One producer worth
ruling out explicitly, since it is the natural guess: unions do not report this
mixture. UnionExec's all-keyed arm returns a bare KeyedPartitioning.concat
(basicPhysicalOperators.scala:994, :1006) and its co-located arm keeps only
HashPartitioningLike and SinglePartition members
(basicPhysicalOperators.scala:1014, :1016-1020), so a keyed child beside a hash
child falls back to UnknownPartitioning.
So this is not a finding. But given the filter is now load-bearing for a
safety gate, please either narrow it to what the base admitted, or pin the
shape as unreachable with a test. If narrowing: satisfies || (keyed &&
isGrouped && keysMaySatisfy) restores the base admission set while keeping the
new ShuffleSpecSuite test, which needs a fallback because strict filtering
would leave the collection empty.
--
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]