peter-toth commented on code in PR #59863:
URL: https://github.com/apache/spark/pull/59863#discussion_r4411054861
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -924,15 +926,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))
+ // A spread or replicate pairing keeps its nodes even when the
grouping is the identity:
+ // a re-run peels a local sort only through a `GroupPartitionsExec`,
and that peel is how
+ // the statistics-based replicate-side choice is reproduced.
+ if (!applyPartialClustering &&
Review Comment:
**Finding 1.** This keeps the node only when partial clustering was
*applied*. But the replicate-side choice above runs, and reads the statistics,
in one more case: partial clustering is enabled, and the statistics pick a side
the join type cannot replicate. Lines 871-880 then decline,
`applyPartialClustering` stays false, and an identity side loses its node.
The reply in
[r4408058934](https://github.com/apache/spark/pull/59863#discussion_r4408058934)
rules this case out ("with the config on but replication not applicable there
is no choice to reproduce"). That holds only when neither side can replicate,
i.e. `FullOuter`. For `LeftOuter`, `RightOuter`, `LeftSemi`, `LeftAnti`,
`ExistenceJoin` and `LeftSingle` there is a choice. A whole-plan re-run no
longer reproduces it:
1. The re-run is `ConvertSortMergeJoinToShuffledHashJoin` after converting
another join in the plan, or a force-applied `OptimizeSkewedJoin`.
2. `keepArrivedPairing` is off under the config (line 642).
3. `peelGroupPartitions` keeps `SortExec(scan)`, because no node sits under
it (line 1069).
4. That `SortExec` has no `logicalLink`, so the choice falls back to
partition counts (lines 853-863).
5. The counts can pick the other side, which this join type does allow.
6. Partial clustering is applied after all, on the side the statistics say
is larger.
I ran the shape of the new SPARK-58996 test with AQE on, swapping `JOIN` for
`LEFT JOIN` / `LEFT SEMI JOIN`. "Re-run" means the UNION's other branch
converts to a shuffled hash join (the `100m` local-map threshold). The values
are `distributePartitions` of the storage-partitioned join's
`GroupPartitionsExec`s, left = `pc_small`, right = `pc_large`:
| join | re-run | master `8e8f37f` | this PR `5c97c1c` | with the change
below |
|---|---|---|---|---|
| `JOIN` | no / yes | `false` / `true` | `false` / `true` | `false` / `true`
|
| `LEFT JOIN` | no | `false` / `false` | `false` / no node | `false` /
`false` |
| `LEFT JOIN` | yes | `false` / `false` | **`true` / `false`** | `false` /
`false` |
| `LEFT SEMI JOIN` | yes | `false` / `false` | **`true` / `false`** |
`false` / `false` |
On the two bold rows the join also goes from 3 partitions to 5. The `LEFT
JOIN` re-run plan on this PR, trimmed:
```
SortMergeJoin [id#38L], [id#40L], LeftOuter
:- Sort [id#38L ASC NULLS FIRST], false, 0
: +- GroupPartitions ExpectedPartitionKeys: 3 DistributePartitions: true
SortedMerge: false
: +- Project [id#38L]
: +- BatchScan testcat.ns.pc_small[id#38L]
+- GroupPartitions ExpectedPartitionKeys: 3 DistributePartitions: false
SortedMerge: false
+- Sort [id#40L ASC NULLS FIRST], false, 0
+- Project [id#40L]
+- Filter isnotnull(id#40L)
+- BatchScan testcat.ns.pc_large[id#40L]
```
The first pass left `Sort(pc_large)` with no node. The re-run built the
replicating node over it.
The rows match on every arm. With AQE off there is no re-run, and this PR
plans like master.
So this is a performance regression against master, and planning is not
idempotent across the rule's re-runs. Master and the first pass both decline
partial clustering here, because the side the statistics pick cannot replicate
for the join type. The re-run instead replicates `pc_large`, the side the
statistics say is larger, and reads it once per split of the matching
`pc_small` key. The config's doc promises the statistics decide: "Spark will
pick the side with less data size based on table statistics"
(`SQLConf.scala:2663`).
Keeping the node whenever the choice ran, applied or not, fixes it:
```scala
var applyPartialClustering = false
// Whether a replicate side was chosen, from statistics or partition
counts. A re-run has to
// make the same choice even when it was then declined for the join
type.
var replicateSideChosen = false
...
} else {
replicateSideChosen = true
// The statistics and the original partition keys come from the
pre-alignment plans,
...
if (!replicateSideChosen &&
gpe.outputPartitioning == child.outputPartitioning) {
```
With that change:
- The table's last column matches master.
- `KeyGroupedPartitioningSuite` and `EnsureRequirementsSuite` pass.
- The motivating join still converts to a shuffled hash join.
- The skip still applies with partial clustering off, and for `FullOuter`
with it on.
Please also:
- Add the `LEFT JOIN` variant of the new test, expecting `false` on both
sides. It fails on this PR, where the left side distributes after the re-run,
and passes with the change.
- Update the comment above and the description's "This is the exact
condition rather than the config" paragraph.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -10759,26 +10891,19 @@ class KeyGroupedPartitioningSuite
val plan = df.queryExecution.executedPlan
assert(ValidateRequirements.validate(plan),
s"the executed plan must satisfy every operator's required
distribution:\n$plan")
- // Locate the marked side's node: no reducers, and output partition
i holds exactly input
- // partition i. That is weaker than `identityGrouping`, which also
asks for one output per
- // input, so the assertions below are what pin the claim and the
three keys.
- val identityGpe = collectAllGroupPartitions(plan).find { g =>
- g.reducers.isEmpty && g.groupedPartitions.zipWithIndex.forall {
- case ((_, inputIndices), outputIndex) =>
- inputIndices.lengthCompare(1) == 0 && inputIndices.head ==
outputIndex
- }
- }
- assert(identityGpe.isDefined,
- s"expected a reducer-free identity GroupPartitionsExec,
got:\n$plan")
- identityGpe.get.outputPartitioning match {
- case k: KeyedPartitioning =>
- assert(k.mayContainUnknownPartitionKeys,
- "the identity grouping must keep the unknown-keyed claim")
- assert(k.numPartitions === 3,
- s"the marked side keeps all three of its declared
keys:\n$plan")
- case other =>
- fail(s"expected a KeyedPartitioning output, got $other")
- }
+ // The marked side's alignment is the pass-through, so no node is
built for it: the
+ // claim has to reach the second join on the side itself. Assert on
that join, since the
+ // first join's keyed shuffle already carries a marked three-key
member of its own.
+ val secondJoin = collect(plan) { case j: SortMergeJoinExec => j
}.head
+ assert(collectAllShuffles(plan).size == 1,
+ s"only the first join shuffles:\n$plan")
+ val marked = collect(secondJoin.left) { case p: SparkPlan =>
p.outputPartitioning }
+ .flatMap(PartitioningCollection.representativeOf)
+ .filter(_.mayContainUnknownPartitionKeys)
Review Comment:
**Finding 3.** This still reads the whole subtree.
`collect(secondJoin.left)` walks down into the first join and its one-side
keyed shuffle of `pt`. The comment just above says that shuffle "already
carries a marked three-key member of its own", so
`marked.exists(_.numPartitions == 3)` holds whatever the second join's left
input reports. I listed the nodes it accepts here: `InputAdapter,
WholeStageCodegen (4), Project, SortMergeJoin, InputAdapter, WholeStageCodegen
(3), Sort, InputAdapter, Exchange`. That is the gap from
[r4404496280](https://github.com/apache/spark/pull/59863#discussion_r4404496280).
The rework in `94d5b1a` moved the starting point to the second join but kept
the walk. Reading the input's own partitioning closes it:
```suggestion
val marked =
PartitioningCollection.representativeOf(secondJoin.left.outputPartitioning)
.filter(_.mayContainUnknownPartitionKeys)
```
`marked` becomes an `Option`, so the `exists` below it is unchanged. With it
the test passes on this PR, on both partition-filter arms.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -1158,6 +1164,55 @@ class KeyGroupedPartitioningSuite
testWithCustomersAndOrders(customers_partitions, Array.empty, 2, 0)
}
+ test("SPARK-60618: Skip the pass-through GroupPartitionsExec in a
storage-partitioned " +
+ "join's alignment") {
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ // `items` reports more partition keys than the join uses, so the
aligned layout has to be
+ // projected onto the join keys. The shuffled side is laid out on that
same projection, so
+ // its `GroupPartitionsExec` would only re-state the keys its shuffle
already has and must
+ // not be built.
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true") {
+ withTable(s"testcat.ns.$items", s"testcat.ns.$purchases") {
+ createTable(items, itemsColumns, Array(identity("id"),
identity("name")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ s"(1, 'aa', 40.0, cast('2020-01-01' as timestamp)), " +
+ s"(1, 'aa', 30.0, cast('2020-01-02' as timestamp)), " +
+ s"(3, 'bb', 10.0, cast('2020-01-01' as timestamp)), " +
+ s"(4, 'cc', 15.5, cast('2020-02-01' as timestamp))")
+ createTable(purchases, purchasesColumns, Array.empty)
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " +
+ s"(1, 42.0, cast('2020-01-01' as timestamp)), " +
+ s"(1, 89.0, cast('2020-01-03' as timestamp)), " +
+ s"(3, 19.5, cast('2020-02-01' as timestamp)), " +
+ s"(5, 26.0, cast('2023-01-01' as timestamp)), " +
+ s"(6, 50.0, cast('2023-02-01' as timestamp))")
+
+ val df = sql(s"SELECT i.id, i.name, i.price AS purchase_price, p.price
AS sale_price " +
+ s"FROM testcat.ns.$items i JOIN testcat.ns.$purchases p ON i.id =
p.item_id")
+ checkAnswer(df, Seq(
+ Row(1, "aa", 30.0, 42.0), Row(1, "aa", 30.0, 89.0),
+ Row(1, "aa", 40.0, 42.0), Row(1, "aa", 40.0, 89.0),
+ Row(3, "bb", 10.0, 19.5)))
+
+ val plan = df.queryExecution.executedPlan
+ // The one-side shuffle still happens; only the regrouping node over
it goes away.
+ assert(collectAllShuffles(plan).size == 1, s"expected one
shuffle:\n$plan")
+ assert(collect(plan) {
+ case g: GroupPartitionsExec
+ if g.child.isInstanceOf[ShuffleQueryStageExec] ||
+ g.child.isInstanceOf[AQEShuffleReadExec] => g
+ }.isEmpty, s"no GroupPartitionsExec should sit over a shuffle
stage:\n$plan")
+ // The source side keeps its node: `items` holds two splits for key
(1, 'aa'), and merging
+ // them is work the join needs.
+ assert(collect(plan) { case _: GroupPartitionsExec => true }.nonEmpty,
+ s"the source side should keep its grouping node:\n$plan")
Review Comment:
**Finding 4.** The test pins that no node sits over the stage. It does not
pin what the description says the change is for: the join converting to a
shuffled hash join. With
`spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold` at its default of 0,
no conversion is attempted.
I ran this test's tables and query with that threshold at `100m`. Master
plans one `SortMergeJoinExec` and two `GroupPartitionsExec`. This PR plans one
`ShuffledHashJoinExec` and one `GroupPartitionsExec`. So asserting the
conversion pins the benefit, and it fails on master. `LEFT OUTER`, `RIGHT
OUTER`, `FULL OUTER`, `LEFT SEMI` and `LEFT ANTI` convert too, with the same
rows as a run without SPJ.
Two smaller points in the same assertions:
- The source-side check matches a node anywhere in the plan.
- The `ShuffleQueryStageExec` / `AQEShuffleReadExec` pattern only exists
under AQE, which the test leaves at the session default.
Suggest adding `SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true"` and
`SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> "100m"` to
the `withSQLConf` above, and:
```suggestion
// Without the node over the stage the join converts to a shuffled
hash join.
val shj = collect(plan) { case j: ShuffledHashJoinExec => j }
assert(shj.size == 1 && collect(plan) { case j: SortMergeJoinExec =>
j }.isEmpty,
s"the join should convert to a shuffled hash join:\n$plan")
// The source side keeps its node: `items` holds two splits for key
(1, 'aa'), and merging
// them is work the join needs.
assert(collect(shj.head.left) { case _: GroupPartitionsExec => true
}.nonEmpty,
s"the source side should keep its grouping node:\n$plan")
```
With these changes the test passes on this PR.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -5416,6 +5477,67 @@ class KeyGroupedPartitioningSuite
}
}
+ test("SPARK-58996: partially clustered join keeps its replicate-side choice
when the " +
+ "alignment is the identity") {
+ // The smaller side by statistics is replicated: `pc_small` holds five
splits in 200 bytes,
+ // `pc_large` four splits in 224. Both are bucketed, whose transform
derives no ordering, so
+ // the join's sort has to be added. The larger side ends up with one split
per bucket, so the
+ // alignment on it is the identity and the pass-through node has to stay:
a re-run peels a
+ // local sort only through a `GroupPartitionsExec`, and reading the
statistics from the scans
+ // again is what keeps the choice. Without the node the counts (five
against four) pick the
+ // other side.
Review Comment:
**Finding 6.** The numbers here don't match the data. `pc_large` gets three
rows, so it holds three splits, and `InMemoryBaseTable.estimateStatistics`
(12-byte header + 60-byte row) gives 216 bytes, not "four splits in 224". The
counts that would decide without the node are five against three, as the PR
description says:
```suggestion
// The smaller side by statistics is replicated: `pc_small` holds five
splits in 200 bytes,
// `pc_large` three splits in 216. Both are bucketed, whose transform
derives no ordering, so
// the join's sort has to be added. The larger side ends up with one
split per bucket, so the
// alignment on it is the identity and the pass-through node has to
stay: a re-run peels a
// local sort only through a `GroupPartitionsExec`, and reading the
statistics from the scans
// again is what keeps the choice. Without the node the counts (five
against three) pick the
// other side.
```
The test pins this PR's gate, so `SPARK-60618:` would be the usual prefix in
its name.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala:
##########
@@ -1757,7 +1753,9 @@ class EnsureRequirementsSuite extends SharedSparkSession {
// The fine pair's members are each their child's own layout, so no
projection is reported and
// the nodes only align to the merged keys. Taking the first agreeing
pair instead groups both
// sides on `a` alone, i.e. positions `Some(Seq(0))`.
- assert(groupPartitionsNodes(planned).map(_.joinKeyPositions) ===
Seq(None, None),
+ // A side whose alignment is the pass-through keeps its own layout and
gets no node, so the
+ // pair shows up as unprojected nodes only.
+ assert(groupPartitionsNodes(planned).forall(_.joinKeyPositions.isEmpty),
Review Comment:
**Finding 5.** `forall` also holds for an empty list, so this no longer
requires the push branch to build anything. Nothing else in this test pins it:
the only other assertion is that nothing was shuffled. The pass-through side
legitimately has no node, but the other side always has one here, so the exact
shape can be asserted:
```suggestion
assert(groupPartitionsNodes(planned).map(_.joinKeyPositions) ===
Seq(None),
```
The same relaxation is in two more places:
- `EnsureRequirementsSuite.scala:2967` uses `forall(_ == Some(3))`. The
partition-count assertion right after it still pins the outcome, but `===
Seq(Some(3))` restores the old strength.
- The SPARK-41413 mismatch test reads through `plannedPartitioning` (line
1071), which falls back to the dummy plan's own partitioning. So all three of
its cases pass with no node on either side, while the removed pattern required
one. `assert(groupPartitionsNodes(smj).size == 1)` in each `case smj:
SortMergeJoinExec =>` keeps the requirement that the pruning side is regrouped.
All three replacements pass on this PR.
--
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]