peter-toth commented on code in PR #60058:
URL: https://github.com/apache/spark/pull/60058#discussion_r4469233265
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -4154,6 +4154,65 @@ class KeyGroupedPartitioningSuite extends
DistributionAndOrderingSuiteBase {
}
}
+ test("SPARK-60460: nested SPJ retargets a one-side shuffle onto merged
partition values") {
+ createTable("pa", Array(Column.create("id", IntegerType)),
Array(identity("id")))
+ sql("INSERT INTO testcat.ns.pa VALUES (1), (2), (3)")
+ createTable("pd", Array(Column.create("id", IntegerType)), Array.empty)
+ sql("INSERT INTO testcat.ns.pd VALUES (1), (2), (3)")
+ createTable("pc", Array(Column.create("id", IntegerType)),
Array(identity("id")))
+ sql("INSERT INTO testcat.ns.pc VALUES (1), (2), (3), (4), (5)")
+
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+ val df = sql(
+ """
+ |SELECT /*+ MERGE(pa, pd, pc) */ pa.id, pd.id, pc.id
+ |FROM testcat.ns.pa JOIN testcat.ns.pd ON pa.id = pd.id
+ |JOIN testcat.ns.pc ON pa.id = pc.id
+ |""".stripMargin)
+ val shuffles = collectAllShuffles(df.queryExecution.executedPlan)
+ assert(shuffles.size == 1, "only pd's one-side shuffle should remain")
+ assert(shuffles.head.outputPartitioning.numPartitions == 5,
+ "that shuffle should be retargeted onto the merged keys")
+ checkAnswer(df, Seq(Row(1, 1, 1), Row(2, 2, 2), Row(3, 3, 3)))
+ }
+ }
+ }
+
+ test("SPARK-60460: nested SPJ shuffles when a one-side shuffle is wider than
the outer key") {
+ val cols = Array(
+ Column.create("store", IntegerType),
+ Column.create("dept", IntegerType),
+ Column.create("data", StringType))
+ createTable("sa", cols, Array(identity("store"), identity("dept")))
+ createTable("sb", cols, Array.empty)
+ createTable("sc", cols, Array(identity("dept")))
+ sql("INSERT INTO testcat.ns.sa VALUES (1, 10, 'a10'), (2, 20, 'a20')")
+ sql("INSERT INTO testcat.ns.sb VALUES (1, 10, 'b10'), (2, 20, 'b20')")
+ sql("INSERT INTO testcat.ns.sc VALUES (0, 10, 'c10'), (0, 20, 'c20'), (0,
30, 'c30')")
+
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true",
+ SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+ val df = sql(
+ """
+ |SELECT /*+ MERGE(sa, sb, sc) */ sa.dept, sb.data, sc.data
+ |FROM testcat.ns.sa JOIN testcat.ns.sb
+ |ON sa.store = sb.store AND sa.dept = sb.dept
+ |JOIN testcat.ns.sc ON sa.dept = sc.dept
+ |""".stripMargin)
+ checkAnswer(df, Seq(Row(10, "b10", "c10"), Row(20, "b20", "c20")))
Review Comment:
**Finding 3.** This test passes on branch-4.1 (`336826d`) as well, AQE on
and off. The query selects only `sa.dept`, so the `Project` above the inner
join drops `store`. The inner join's partitioning on `(store, dept)` goes with
it. branch-4.1 then plans an exchange above the inner join (`Exchange
keygroupedpartitioning(dept#28, 3, [10], [20], [30], ...)`), so the nested push
never happens. The data would hide the bug anyway. Two depts and two `(store,
dept)` pairs give both sides two partitions in the same order.
With `sa.store` selected and three pairs, branch-4.1 takes the nested SPJ.
It pushes `dept` into `sa`'s scan and leaves `sb`'s exchange on the three
`(store, dept)` partitions. It silently drops `[2, 10, b2, c10]`. This head
returns all three rows, AQE on and off:
```suggestion
sql("INSERT INTO testcat.ns.sa VALUES (1, 10, 'a1'), (2, 10, 'a2'), (1,
20, 'a3')")
sql("INSERT INTO testcat.ns.sb VALUES (1, 10, 'b1'), (2, 10, 'b2'), (1,
20, 'b3')")
sql("INSERT INTO testcat.ns.sc VALUES (0, 10, 'c10'), (0, 20, 'c20'),
(0, 30, 'c30')")
Seq(true, false).foreach { aqe =>
withSQLConf(
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true",
SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
val df = sql(
"""
|SELECT /*+ MERGE(sa, sb, sc) */ sa.store, sa.dept, sb.data,
sc.data
|FROM testcat.ns.sa JOIN testcat.ns.sb
|ON sa.store = sb.store AND sa.dept = sb.dept
|JOIN testcat.ns.sc ON sa.dept = sc.dept
|""".stripMargin)
checkAnswer(df,
Seq(Row(1, 10, "b1", "c10"), Row(2, 10, "b2", "c10"), Row(1, 20,
"b3", "c20")))
```
The description says this test fails on branch-4.1 "the same way". For this
shape branch-4.1 returns wrong rows rather than failing, which belongs under
the user-facing changes too.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -902,6 +917,92 @@ case class EnsureRequirements(
case other => other.children.exists(hasReducedScanOutsideJoin)
}
+ /** Partition rows a push expands to, one per output partition. */
+ private def pushedPartitionRows(values: Seq[(InternalRow, Int)]):
Seq[InternalRow] =
+ values.flatMap { case (row, n) => Seq.fill(n)(row) }
+
+ private def samePartitionValues(
+ partitioning: KeyGroupedPartitioning,
+ rows: Seq[InternalRow]): Boolean =
+ partitioning.partitionValues.length == rows.length &&
+ partitioning.partitionValues.zip(rows).forall { case (left, right) =>
+ left.numFields == partitioning.expressions.length &&
+ left.numFields == right.numFields &&
+ InternalRowComparableWrapper(left, partitioning.expressions) ==
+ InternalRowComparableWrapper(right, partitioning.expressions)
+ }
+
+ /**
+ * A one-side shuffle can take `values` when it already has them, or when it
evaluates the same
+ * keys and can be rewritten onto them. A reducer changes the value domain,
and partial
+ * clustering replicates partitions, neither of which the shuffle's
partitioner can follow.
+ * Anything other than [[ShuffleExchangeExec]] (a query stage, a reused
exchange) cannot be
+ * rewritten, so it follows only when it already has `values`.
+ */
+ private def retargetKeyGroupedShuffle(
+ exchange: ShuffleExchangeExec,
+ values: Seq[(InternalRow, Int)],
+ reducers: Option[Seq[Option[Reducer[_, _]]]],
+ applyPartialClustering: Boolean): Option[ShuffleExchangeExec] = {
+ exchange.outputPartitioning match {
+ case k: KeyGroupedPartitioning if samePartitionValues(k,
pushedPartitionRows(values)) =>
+ Some(exchange)
+ case k: KeyGroupedPartitioning if !applyPartialClustering &&
Review Comment:
**Finding 1.** For branch-4.1 I'd keep only the refusal and drop this
rewrite. 4.2 and later handle these shapes with `GroupPartitionsExec` above the
inner join, so the retarget is a mechanism only 4.1 would have. It also needs
its own care. With no merged values, every guard of this case holds, since
`forall` and `headOption.forall` are true on `Nil`. The exchange then gets zero
partitions, and `KeyGroupedPartitioner` does `nonNegativeMod(hash, 0)`. With
`partition.filter.enabled`, `pa` and `pd` holding 1 to 3 and `pc` holding 7:
```sql
SELECT /*+ MERGE(pa, pd, pc) */ pa.id, pd.id, pc.id
FROM pa JOIN pd ON pa.id = pd.id
JOIN pc ON pa.id = pc.id
```
- This head, AQE on: `ArithmeticException: / by zero`. AQE off returns the
empty result.
- branch-4.1 (`336826d`): `PartitioningCollection requires all of its
partitionings have the same numPartitions` with AQE on, `Can't zip RDDs with
unequal numbers of partitions: List(0, 3)` with AQE off.
Refusing costs one shuffle, of the inner join's output, only where the
retarget fires. I measured it on this head with the rewrite removed, AQE on and
off:
- The motivating query, the reordered values and the partition-filter `RIGHT
JOIN` return the right rows with 2 shuffles instead of 1. branch-4.1 fails or
returns wrong rows on all three.
- The query above returns the empty result in both modes.
- Every other shape from this review keeps its rows and its shuffle count,
once the change from finding 2 is in.
The prototype pushes only when every key-grouped shuffle the push reaches
already has exactly the merged values, and no reducer is pushed. It removes
`retargetKeyGroupedShuffle`, `outputFollowsPush` and the new
`ShuffleExchangeExec` case in `populateSpjParams`, which goes back to `case s:
ShuffleExchangeLike => s`:
```scala
/**
* Whether every key-grouped shuffle [[populateSpjParams]] would reach
below `plan` already has
* `values`. The push stops at such a shuffle, which was planned at its
own values, so the push is
* sound only when it changes nothing there: the same values in the same
order, and no reducer.
* The walk stops where the push stops.
*/
private def nestedShufflesFollowPush(
plan: SparkPlan,
values: Seq[(InternalRow, Int)],
reducers: Option[Seq[Option[Reducer[_, _]]]]): Boolean = plan match {
case _: BroadcastExchangeLike => true
case s @ (_: ShuffleExchangeLike | _: ShuffleQueryStageExec | _:
ReusedExchangeExec) =>
s.outputPartitioning match {
case k: KeyGroupedPartitioning =>
reducers.forall(_.forall(_.isEmpty)) &&
samePartitionValues(k, pushedPartitionRows(values))
case _ => false
}
case other => other.children.forall(nestedShufflesFollowPush(_, values,
reducers))
}
```
With it and the change from finding 2, `KeyGroupedPartitioningSuite` and
`EnsureRequirementsSuite` pass, 155 tests. The first new test then expects two
shuffles, of 3 and 5 partitions.
The reducer condition comes from reading the code. With a pushed reducer,
the scan regroups on the reduced values while the exchange routes on the raw
ones. Equal value lists keep the two aligned only when the reducer maps each of
those values to itself. The test catalog's bucket reducers always do, so I
couldn't build a failing case, but the `ReducibleFunction` contract doesn't
promise it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -230,7 +230,11 @@ case class EnsureRequirements(
!childrenIndexes.contains(idx) =>
child
case ((child, dist), idx) =>
- if (bestMemberOpt.isDefined && matchedIndexes.contains(idx)) {
+ // A nested one-side shuffle is already routed at its own partition
count. Pushing join
+ // key positions into the scan beside it would regroup that scan and
leave the shuffle
+ // behind, so this child is shuffled onto the best spec instead.
+ if (bestMemberOpt.isDefined && matchedIndexes.contains(idx) &&
+ !hasKeyGroupedShuffleUnder(child)) {
Review Comment:
**Finding 2.** This also catches a matched child that the push leaves alone:
- The matching member has no join key positions, which is the default
(`allowJoinKeysSubsetOfPartitionKeys.enabled=false`). The `case _ => child`
below keeps such a child as it is.
- The subset conf is on, and the positions select every partition
expression. Pushing them doesn't regroup the scan.
In both cases the nested shuffle still lines up with the scan beside it. But
the child now takes the `else` branch and is shuffled onto its own layout.
Measured with `pa` partitioned by `id`, `pd` and `pe` not partitioned, each
holding 1 to 3, and `shuffle.enabled=true`, AQE on and off:
```sql
SELECT /*+ MERGE(pa, pd, pe) */ pa.id, pd.id, pe.id
FROM pa JOIN pd ON pa.id = pd.id
JOIN pe ON pa.id = pe.id
```
- branch-4.1 (`336826d`): 2 shuffles, for `pd` and `pe`.
- This head: 3 shuffles. The inner join's output is shuffled again, onto
`keygroupedpartitioning(id, 3, [1], [2], [3])`.
The rows are right on both, and the subset conf gives the same counts. A
fact table joined to two unpartitioned tables on its partition key is a common
shape. The reducer shape from the ticket's [second
comment](https://issues.apache.org/jira/browse/SPARK-60460?focusedCommentId=18136631#comment-18136631),
which fails on branch-4.1, has 3 shuffles here for the same reason.
The check only has to fire when the positions narrow the layout the nested
shuffle routes on. Comparing them with the matching member's own expressions is
not enough, because those are already projected onto the positions. An outer
join on `store`, a prefix of `(store, dept)`, then pushes `[0]` and fails at
planning (the `store` test in finding 4). Comparing them with the nested
shuffle's expressions works. I ran this version:
- the query above has 2 shuffles again, with and without the subset conf;
- the reducer shape has 2 shuffles instead of 3;
- the narrowed-positions shapes from finding 4 still return the right rows;
- `KeyGroupedPartitioningSuite` and `EnsureRequirementsSuite` pass, 155
tests.
```scala
// A nested one-side shuffle keeps its own layout. Join key
positions that narrow it would
// regroup the scan beside it and leave the shuffle behind, so
this child is shuffled
// onto the best spec instead.
if (bestMemberOpt.isDefined && matchedIndexes.contains(idx) &&
!positionsRegroupBesideShuffle(child, specs(idx),
bestMemberOpt.get)) {
```
```scala
/**
* Whether pushing the join key positions of the member of `spec` that
matches `bestMember`
* would regroup a scan beside a one-side shuffle below `plan`. The
shuffle keeps its own layout,
* so only positions that select all of its partition expressions keep the
two aligned. The walk
* stops where the push stops.
*/
private def positionsRegroupBesideShuffle(
plan: SparkPlan,
spec: ShuffleSpec,
bestMember: ShuffleSpec): Boolean = {
def regroups(p: SparkPlan, positions: Seq[Int]): Boolean = p match {
case _: BroadcastExchangeLike => false
case s @ (_: ShuffleExchangeLike | _: ShuffleQueryStageExec | _:
ReusedExchangeExec) =>
s.outputPartitioning match {
case k: KeyGroupedPartitioning => positions !=
k.expressions.indices
case _ => false
}
case other => other.children.exists(regroups(_, positions))
}
flattenSpec(spec).find(bestMember.isCompatibleWith) match {
case Some(KeyGroupedShuffleSpec(_, _, Some(positions))) =>
regroups(plan, positions)
case _ => false
}
}
```
`hasKeyGroupedShuffleUnder` and `containsKeyGroupedShuffle` have no callers
after that.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -4154,6 +4154,65 @@ class KeyGroupedPartitioningSuite extends
DistributionAndOrderingSuiteBase {
}
}
+ test("SPARK-60460: nested SPJ retargets a one-side shuffle onto merged
partition values") {
Review Comment:
**Finding 4.** Both new tests stay green when the positions check is
removed, and when the values are compared without their order. The ticket's
comments and a partition-filter `RIGHT JOIN` give shapes that catch both. I ran
each one on this head, with the refusal from finding 1, and on branch-4.1
(`336826d`), AQE on and off:
- **Narrowed positions:** the corrected test from finding 3, with `sc` on
depts 10 and 20 only. Also the same tables with `sc` partitioned by `store`
(stores 1 and 2) and the outer join on `store`. branch-4.1 fails both at
planning with `PartitioningCollection requires all of its partitionings have
the same numPartitions`. So does this head without the
`hasKeyGroupedShuffleUnder` check. The ticket's [`da`/`db`/`de`
query](https://issues.apache.org/jira/browse/SPARK-60460?focusedCommentId=18132917#comment-18132917)
narrows the same way.
- **Reordered values**
([comment](https://issues.apache.org/jira/browse/SPARK-60460?focusedCommentId=18132917#comment-18132917)):
branch-4.1 returns no rows instead of 2, with no error. `sb`'s exchange keeps
`[20], [10]` while `sa`'s scan is sorted to `[10], [20]`. A check that compares
the two value lists as sets returns no rows too.
- **Partition filter:** branch-4.1 returns `[null,null,2]`, `[null,null,3]`,
`[null,null,4]` instead of `[2,2,2]`, `[3,3,3]`, `[null,null,4]`. `pa`'s scan
is filtered to `pc`'s keys, while `pd`'s exchange keeps `[1], [2], [3]`.
- **Reducer**
([comment](https://issues.apache.org/jira/browse/SPARK-60460?focusedCommentId=18136631#comment-18136631)):
branch-4.1 fails at planning or execution.
```sql
-- pa PARTITIONED BY (id): 1, 2, 3 pd not partitioned: 1, 2, 3
-- pc PARTITIONED BY (id): 2, 3, 4
-- shuffle.enabled=true, partition.filter.enabled=true
SELECT /*+ MERGE(pa, pd, pc) */ pa.id, pd.id, pc.id
FROM pa JOIN pd ON pa.id = pd.id
RIGHT JOIN pc ON pa.id = pc.id
```
All of them return the right rows here, with and without the retarget. The
reordered and partition-filter shapes returned wrong rows on branch-4.1 with no
error, so they belong in the user-facing section too.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -4154,6 +4154,65 @@ class KeyGroupedPartitioningSuite extends
DistributionAndOrderingSuiteBase {
}
}
+ test("SPARK-60460: nested SPJ retargets a one-side shuffle onto merged
partition values") {
+ createTable("pa", Array(Column.create("id", IntegerType)),
Array(identity("id")))
+ sql("INSERT INTO testcat.ns.pa VALUES (1), (2), (3)")
+ createTable("pd", Array(Column.create("id", IntegerType)), Array.empty)
+ sql("INSERT INTO testcat.ns.pd VALUES (1), (2), (3)")
+ createTable("pc", Array(Column.create("id", IntegerType)),
Array(identity("id")))
+ sql("INSERT INTO testcat.ns.pc VALUES (1), (2), (3), (4), (5)")
+
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+ val df = sql(
+ """
+ |SELECT /*+ MERGE(pa, pd, pc) */ pa.id, pd.id, pc.id
+ |FROM testcat.ns.pa JOIN testcat.ns.pd ON pa.id = pd.id
+ |JOIN testcat.ns.pc ON pa.id = pc.id
+ |""".stripMargin)
+ val shuffles = collectAllShuffles(df.queryExecution.executedPlan)
Review Comment:
**Finding 5.** Under AQE, `executedPlan` before execution is the initial
adaptive plan. So with `aqe = true` these assertions don't see what
re-optimization does once the stages materialize. Reading the shuffles after
`checkAnswer`, which runs the query, would check the final plan.
--
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]