dongjoon-hyun commented on code in PR #59165:
URL: https://github.com/apache/spark/pull/59165#discussion_r4159568724
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -1121,8 +1125,8 @@ case class EnsureRequirements(
// the skew of joining on keys that are coarser than the join keys. Key
order and duplicated
// cluster keys don't matter.
def allClusterKeysCovered: Boolean =
- // The single-column invariant in
KeyedPartitioning.supportsExpressions guarantees one
- // attribute per partition expression.
+ // Every column a partition expression references counts as covered. A
scan reports one
Review Comment:
**A member over two columns passes this check, and
`KeyedPartitioning.createShuffleSpec` then projects it away.** This counts
every column that `p.b + p.c` references as covered, but `keyPositions` now
maps `p.b + p.c` to no position (partitioning.scala:1964-1968). So under
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`,
`createShuffleSpec` drops that position (partitioning.scala:1015). For
instance, take two sides that each report `[p.b + p.c, p.d]`, e.g. the output
of a broadcast join `ON i.x = p.b + p.c AND i.d = p.d` over a table partitioned
by `identity(x), identity(d)`. A join of the two on `(b, c, d)` passes this
check with `{b, c, d}`, but both specs are projected to `[p.d]`, and the
storage-partitioned join pairs them on `d` alone. The rows are right, but the
join runs on as many partitions as there are distinct values of `d`, which is
the skew `spark.sql.requireAllClusterKeysForCoPartition` is there to prevent.
On the base the same plan failed th
e `keyPositions` assertion, so it is a newly reachable plan rather than a
regression. Could this count only the columns of expressions with a single
reference, or check the projected spec instead?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2061,7 +2074,15 @@ case class KeyedShuffleSpec(
left: Expression,
right: Expression,
allowReduce: Boolean): Boolean = {
- if (TransformExpression.hasReducedKeys(left) ||
TransformExpression.hasReducedKeys(right)) {
+ val overExpression = Seq(left, right).exists {
Review Comment:
**The costs listed in the description now also reach plans that use only
default confs, through the broadcast expansion.** The description says an inner
broadcast hash join builds such a layout with every conf at its default, but
the costs still read as if they need
`spark.sql.sources.v2.bucketing.shuffle.enabled`. For example, take `orders`
and `returns` bucketed by `bucket(16, cust_id)` on a `BIGINT` column, and an
`INT` `customers.id` broadcast into both:
```sql
SELECT a.id FROM
(SELECT c1.id FROM orders o JOIN customers c1 ON o.cust_id = c1.id) a
JOIN
(SELECT c2.id FROM returns r JOIN customers c2 ON r.cust_id = c2.id) b
ON a.id = b.id
```
Each broadcast join also reports `bucket(16, cast(cN.id as bigint))`. The
base pairs the two as they stand, with no shuffle, and that is correct since
both sides have the same shape. With this guard, both fact-sized sides are
hash-shuffled, since keyed shuffles are off by default. The `t.id = d.k + 1`
shape behaves the same. It is the first cost in the list, but on the
maintenance branches it changes plans for users who set nothing. Could the
description say that these costs apply with default confs too?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -604,8 +604,15 @@ private[sql] object GroupPartitionsExec {
// `reduced` came from the one member
`checkKeyGroupCompatible` paired
// this side on, which need not be the member being
rewritten. The keys are
// reduced once, from the shared key rows, so `reduced`
describes them
- // whichever member this is, and only the key attribute is
re-targeted.
- reduced.withReference(expr.references.head)
+ // whichever member this is. Its argument is a bare column,
since the pairing
+ // admits no other. The member's own argument takes its
place, since that need
+ // not be one. A side shuffled onto this layout reports
`bucket(8, b + 1)` next
+ // to `bucket(8, id)`, or `b + 1` next to an identity `id`.
+ val argument = expr match {
Review Comment:
**This also fixes a `GROUP BY` or window over a member with two columns, but
no test covers that.** The base re-targeted a `p.b + p.c` member at its first
reference, `bucket(2, p.b)`, which satisfies `ClusteredDistribution([p.b])`, so
a `GROUP BY p.b` or `PARTITION BY p.b` above it skipped its shuffle. With
`spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled` on, `plain2`
holding `(0, 1), (0, 2), (1, 1), (1, 2)`, and the `ident` and `bucket2` tables
of the new tests:
```scala
sql("""SELECT /*+ BROADCAST(p) */ p.b, max(p.c) FROM testcat.ns.ident i
|JOIN testcat.ns.plain2 p ON i.id = p.b + p.c
|JOIN testcat.ns.bucket2 t2 ON i.id = t2.id
|GROUP BY p.b""".stripMargin)
```
The base returns `(0, 2), (1, 1), (0, 1), (1, 2)`, since the rows of `b = 0`
sit in two buckets, and this PR returns `(0, 2), (1, 2)`. The aggregate has to
keep `p.c`: with `count(*)`, column pruning drops the member before the second
join. The reduce test (KeyGroupedPartitioningSuite.scala:1647) covers
single-column members under a later join only, and the SPARK-59901 test
(KeyGroupedPartitioningSuite.scala:1707) has no reduce. Could one of them add
this shape, or its `row_number() OVER (PARTITION BY p.b ORDER BY p.c)` variant?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -367,12 +369,14 @@ case class EnsureRequirements(
val shouldConsiderMinParallelism = children.zip(specs).forall { case
(child, spec) =>
spec.forall(!_.canCreatePartitioning) ||
child.isInstanceOf[ShuffleExchangeLike]
}
- // Choose all the specs that can be used to shuffle other children
+ // Choose all the specs that can be used to shuffle other children,
keeping only the members
+ // that can serve as the layout. Any other member would build a
partitioning its own child is
+ // not laid out on.
val candidateSpecs = children.zip(specs).collect {
case (child, Some(spec)) if spec.canCreatePartitioning &&
(!shouldConsiderMinParallelism ||
child.outputPartitioning.numPartitions >=
conf.defaultNumShufflePartitions) =>
- child -> spec
+ child -> spec.flatten.filter(_.canCreatePartitioning)
Review Comment:
**This filter is not only defensive: it can change which child the layout
comes from.** The description says that removing it changes no test, since the
guard already keeps a refused member from pairing with its own side. That holds
for the pairing, but the ranking at L391 now takes the max over the serving
members only, and with
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` the
members of one collection can project to different counts. For example, with
that conf and `spark.sql.sources.v2.bucketing.shuffle.enabled` on, take `t1`
partitioned by `identity(a), identity(c)` with keys `(1, 1), (1, 2), (1, 3),
(2, 4), (2, 5)`, and `SELECT /*+ BROADCAST(p) */ * FROM t1 JOIN p ON t1.c = p.y
+ 1 JOIN q ON t1.a = q.m AND p.y = q.n`, where `q` reports 3 partitions without
an exchange. The broadcast join reports `[a, c]` and `[a, y + 1]`. The first
projects to `[a]`, 2 partitions, and can serve. The second keeps 5 and cannot.
With the filter, `q` wins with 3, as on
the base. Without it, `t1` ranks at 5 and wins, and `q` is shuffled onto 2
partitions. Both plans are correct, so only the parallelism differs. Could the
description say so, and could a test in EnsureRequirementsSuite pin the ranking?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1588,15 +1584,21 @@ sealed trait ShuffleSpec {
* co-partitioned with the `other`, and therefore no shuffle is required
when joining the two
* sides.
*
- * Note that Spark assumes this to be reflexive, symmetric and transitive.
+ * Note that Spark assumes this to be reflexive, symmetric and transitive.
Some specs are not
+ * reflexive:
+ * - a [[RangeShuffleSpec]], whose boundaries are sampled;
+ * - a [[KeyedShuffleSpec]] with a partition expression that is neither a
column nor a transform
Review Comment:
Minor: following up on R1-10, this list reads as complete, but it misses
specs over a column, or a transform of a column, whose `keyPositions` entry is
empty. `years(ts)` clustered on `years(ts)` maps to nothing, since
`keyPositions` looks up `ts` (EnsureRequirementsSuite, "SPARK-59289: a
partitioning is never projected onto no position"). So does the unprojected
spec of a marked narrowing projection (ShuffleSpecSuite, "createShuffleSpec: a
marked narrowing projection yields an unusable spec"). Both fail
`left.intersect(right).nonEmpty` in `areKeysCompatible` against themselves,
while the last sentence still holds for them. Could the note state the two
conditions, a partition expression that maps to no cluster key in
`keyPositions`, or one that is neither a column nor a transform of columns,
instead of listing shapes?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1941,8 +1943,16 @@ case class KeyedShuffleSpec(
* `KeyedPartitioning.positionsCoveringClusterKeys` counts it. See that
method for why the two
* stay apart.
*
- * The assertion is what makes the reference reading sound. With one
reference per expression,
- * "some reference is a cluster key" and "every reference is" are the same
statement.
+ * Only an expression with a single reference maps to a position. With one
reference, "some
+ * reference is a cluster key" and "every reference is" are the same
statement, which is what
+ * makes the reference reading sound. A scan reports no other expression
Review Comment:
Minor: this paragraph names `createPartitioning` as the only producer, and
its last sentence holds only in a narrow case. Since 5375cc3, the description
also names the broadcast expansion, which builds such an expression with
default confs. And `createShuffleSpec` projects the position away only under
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`, for an
unmarked layout where another position maps. It returns a marked layout
unprojected at L1030-L1031, and every `createPartitioning` output is marked
(L2270-L2272). It returns the single `bucket(4, b + c)` of this example
unprojected too, at L1039-L1040, and the comment at L1036-L1037 still
attributes that only to "a clustering that names the partition expression
itself". EnsureRequirements.scala:143-145 likewise mentions only "a side
shuffled onto another side's layout". Could these comments name both producers,
and say when the projection happens?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -1520,6 +1534,203 @@ class KeyGroupedPartitioningSuite
}
}
+ // Each key is a function of `b` other than `b` itself. A check on the
leaves of the key would
+ // reject `b + 1` for its literal, but not `-b`, so both are tested.
+ Seq("p.b + 1" -> ((b: Long) => b + 1), "-p.b" -> ((b: Long) => -b)).foreach {
+ case (key, keyOf) =>
+ // `plain` holds `b` in 0..7, and `keys`, bucketed on `id`, holds the
key of each `b`.
+ def createKeyTables(): Unit = {
+ createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+ sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b =>
s"($b)").mkString(", "))
+ createTable("keys", Array(Column.create("id", LongType)),
Array(bucket(4, "id")))
+ sql("INSERT INTO testcat.ns.keys VALUES " +
+ (0 until 8).map(b => s"(${keyOf(b)})").mkString(", "))
+ }
+
+ test("SPARK-59887: a side shuffled onto a transform of an expression is
not paired as it " +
+ s"is ($key)") {
+ createKeyTables()
+ Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds =
8))
+
+ // The first join shuffles `plain` onto the bucket of its key. That is
a function of `b`,
+ // but not the same function of it as the third table's `bucket(n,
id)` is of `id`. The
+ // second join clusters `plain` on `b`. Pairing the two there, as they
stand or after
+ // reducing `bucket(8, id)` onto 4 buckets, puts a row with `b = id`
on a different
+ // partition on each side.
+ Seq(4 -> "false", 8 -> "true").foreach { case (third,
allowCompatibleTransforms) =>
+ withSQLConf(
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key ->
allowCompatibleTransforms) {
+ val df = sql(
+ s"""SELECT k.id, p.b, t.id FROM testcat.ns.keys k
+ |JOIN testcat.ns.plain p ON k.id = $key
+ |JOIN testcat.ns.bucket$third t ON p.b = t.id""".stripMargin)
+ checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong,
b.toLong)))
+ // One shuffle per join: `plain` for the first, the first join's
output for the
+ // second, and none for the third table. The second join does not
shuffle onto the
+ // bucket of the key, since that would move both of its sides.
+
assert(collectShuffles(stripAQEPlan(df.queryExecution.executedPlan)).size == 2)
+ }
+ }
+ }
+
+ test(s"SPARK-59887: a side is not shuffled onto a transform of an
expression ($key)") {
+ createKeyTables()
+ createTable("xy", Array(Column.create("x", LongType),
Column.create("y", LongType)),
+ Array.empty)
+ sql("INSERT INTO testcat.ns.xy VALUES " +
+ (0 until 8).map(b => s"(${keyOf(b)}, $b)").mkString(", "))
+
+ // The first join shuffles `plain` onto the bucket of its key, so its
output offers that
+ // layout next to `bucket(4, id)`. The second join must not shuffle
`xy` onto the first
+ // one. That builds `bucket(4, y)` for `xy`, not the bucket of the
same function of `y`. A
+ // row with `b = y` then lands in a different bucket on each side. The
join order decides
+ // which of the two layouts comes first, so both orders run.
+ withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+ Seq(
+ s"testcat.ns.plain p JOIN testcat.ns.keys k ON $key = k.id",
+ s"testcat.ns.keys k JOIN testcat.ns.plain p ON k.id =
$key").foreach { firstJoin =>
+ val df = sql(
+ s"""SELECT k.id, p.b, q.x FROM $firstJoin
+ |JOIN testcat.ns.xy q ON k.id = q.x AND p.b =
q.y""".stripMargin)
+ checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong,
keyOf(b))))
+ // `xy` is shuffled onto the usable layout, `bucket(4, x)`, and
never onto a bucket of
+ // `y`. So `plain` and `xy` are the only sides that move.
+ val shuffles =
collectShuffles(stripAQEPlan(df.queryExecution.executedPlan))
+ assert(shuffles.size == 2)
+
assert(!keyedPartitioningsOf(shuffles).exists(_.references.exists(_.name ==
"y")))
+ }
+ }
+ }
+
+ test("SPARK-59887: a broadcast join's layout over a join key is not
paired as it is " +
+ s"($key)") {
+ createKeyTables()
+ createBucketedIdTable("bucket4", 4, numIds = 8)
+
+ // An inner broadcast join reports the streamed side's layout over the
build side's join
+ // key too, so the first join also offers the bucket of `plain`'s key.
That needs no conf
+ // beyond the defaults. The second join clusters `plain` on `b`, so
pairing that layout with
+ // `bucket(4, id)` as it stands puts a row with `b = id` on a
different partition on each
+ // side.
+ val df = sql(
+ s"""SELECT /*+ BROADCAST(p) */ k.id, p.b, t.id FROM testcat.ns.keys k
+ |JOIN testcat.ns.plain p ON k.id = $key
+ |JOIN testcat.ns.bucket4 t ON p.b = t.id""".stripMargin)
+ checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong,
b.toLong)))
+ assert(collect(df.queryExecution.executedPlan) { case j:
BroadcastHashJoinExec => j }
+ .nonEmpty, "the first join is a broadcast join")
+ }
+ }
+
+ test("SPARK-59887: a layout over a transform of a struct field is not paired
nor shuffled onto") {
+ createBucketedIdTable("bucket4", 4, numIds = 8)
+ createStructTable()
+ // `l` shuffles `ps` onto `bucket(4, s.a)`, and `r` shuffles it onto
`bucket(4, s.b)`.
+ val l = "SELECT x.id, ps.s FROM testcat.ns.bucket4 x JOIN testcat.ns.ps ps
ON x.id = ps.s.a"
+ val r = "SELECT y.id, qs.s FROM testcat.ns.bucket4 y JOIN testcat.ns.ps qs
ON y.id = qs.s.b"
+
+ withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+ // Both layouts are functions of `s`, but not the same one, so the join
on `s` must not pair
+ // them as they stand. A row lands in bucket `a % 4` on one side and `b
% 4` on the other.
+ checkAnswer(
+ sql(s"SELECT l.id, r.id FROM ($l) l JOIN ($r) r ON l.s = r.s"),
+ (0 until 8).map(i => Row(i.toLong, (7 - i).toLong)))
+ // Nor may a join shuffle a plain `ps` onto `l`'s layout. That builds
`bucket(4, s)` for it,
+ // which drops the `.a` and hands the whole struct to a bucket function
over a long.
+ checkAnswer(
+ sql(s"SELECT l.id FROM ($l) l JOIN testcat.ns.ps r ON l.s = r.s"),
+ (0 until 8).map(i => Row(i.toLong)))
+ }
+ }
+
+ test("SPARK-59887: a reduce keeps what each member's keys are computed
from") {
+ Seq(4, 2).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 8))
+ createIdentityIdTable()
+ createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+ sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b =>
s"($b)").mkString(", "))
+ createStructTable()
+
+ withSQLConf(
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+ // The first join shuffles `plain` onto `bucket(4, b + 1)`, or onto `b +
1` for the
+ // identity-partitioned `ident`. The second join reduces the first one's
output onto
+ // `bucket(2)`, `plain`'s member included. That member is still over `b
+ 1`. So the third
+ // join, on the bare `b`, must neither pair it with `bucket(2, id)` nor
shuffle a plain
+ // side onto it.
+ for {
+ first <- Seq("bucket4", "ident")
+ (third, thirdKey) <- Seq("bucket2" -> "id", "plain" -> "b")
+ } {
+ val df = sql(
+ s"""SELECT f.id, p.b, t2.id, t3.$thirdKey FROM testcat.ns.$first f
+ |JOIN testcat.ns.plain p ON f.id = p.b + 1
+ |JOIN testcat.ns.bucket2 t2 ON f.id = t2.id
+ |JOIN testcat.ns.$third t3 ON p.b = t3.$thirdKey""".stripMargin)
+ checkAnswer(df, (0 until 7).map(b => Row(b + 1L, b.toLong, b + 1L,
b.toLong)))
+ }
+
+ // The same for a struct field. `ps`'s member is still over `s.a` after
the reduce, so the
+ // third join must not shuffle a plain `ps` onto it, which would build
`bucket(2, s)`.
+ val df = sql(
+ """SELECT x.id FROM testcat.ns.bucket4 x
+ |JOIN testcat.ns.ps ps ON x.id = ps.s.a
+ |JOIN testcat.ns.bucket2 t2 ON x.id = t2.id
+ |JOIN testcat.ns.ps r ON ps.s = r.s""".stripMargin)
+ checkAnswer(df, (0 until 8).map(i => Row(i.toLong)))
+ }
+ }
+
+ test("SPARK-59887: an identity side is not reduced onto a transform of an
expression") {
+ createBucketedIdTable("bucket4", 4, numIds = 8)
+ createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+ sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 7).map(b =>
s"($b)").mkString(", "))
+ createIdentityIdTable((0L until 8L) :+ Long.MaxValue)
+
+ // The first join shuffles `plain` onto `bucket(4, b + 1)`. Reducing
`ident` onto it would
+ // evaluate `id + 1` on `ident`'s partition keys while planning, and that
overflows for
+ // `Long.MaxValue` under ANSI. The query itself never computes it, since
no `b` matches that
+ // key.
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+ val df = sql(
+ """SELECT k.id, p.b, i.id FROM testcat.ns.bucket4 k
+ |JOIN testcat.ns.plain p ON k.id = p.b + 1
Review Comment:
**This test would pass on branch-4.2 without the fix.** It is the only
end-to-end test of the identity arm, and it runs with `p.b + 1` only. As the
description says, branch-4.2 never pairs `p.b + 1`, because its
`createKeyedShuffleSpec` compares the leaves of the partition expressions with
the clustering and sees the literal. But branch-4.2 has the identity arm too,
`IdentityReducer(t.withReference(a))`, so `-p.b` does pair with `ident` there,
and the planner evaluates `bucket(4, -id)` on the keys of `ident`. Under ANSI
that fails with `ARITHMETIC_OVERFLOW` for a key of `Long.MinValue`, although
the query never negates it. The two expression tests above run both keys for
the same reason. Could this test run `-p.b` as well, with `Long.MinValue` in
`ident`?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1892,8 +1894,8 @@ case class KeyReducer(reducer: Reducer[_, _],
reducedExpression: TransformExpres
* @param transform the partition transform, re-targeted at the identity
side's key attribute
*/
case class IdentityReducer(transform: TransformExpression) extends
Reducer[Any, Any] {
- // `transform` has a single leaf attribute
(`KeyedPartitioning.supportsExpressions`), which is
- // bound to ordinal 0 of the single-value row `reduce` evaluates it against.
+ // `transform` is over a single bare column
(`KeyedShuffleSpec.isExpressionCompatible` admits no
Review Comment:
Nit: this credits `isExpressionCompatible` with the single column, but
`argumentsAreColumns` admits any number of columns. The single reference comes
from `keyPositions`, as the comment at L2203-L2205 says. Relatedly, step 3.3 of
`isCompatibleWith` (L1982-L1985) still says that "the same transform function,
or the two sides of one reduce" describe one key space, while the guard now
refuses both when an argument is not a column, as ShuffleSpecSuite.scala:807
and ShuffleSpecSuite.scala:815 assert. Could both comments mention the argument
condition?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1951,8 +1961,11 @@ case class KeyedShuffleSpec(
}
partitioning.expressions.map { e =>
val refs = e.references
- assert(refs.size == 1, s"Expected exactly one child from $e, but found
${refs.size}")
- distKeyToPos.getOrElse(refs.head.canonicalized, mutable.BitSet.empty)
+ if (refs.size == 1) {
+ distKeyToPos.getOrElse(refs.head.canonicalized, mutable.BitSet.empty)
Review Comment:
Minor: how about mapping by the argument here instead of by the reference?
`createPartitioning` replaces the argument of a transform, and
`GroupPartitionsExec` now rebuilds over it (GroupPartitionsExec.scala:611-615),
but this maps an expression by its single reference. If it looked up
`t.children.head` for a single-child transform, and the expression itself
otherwise, every shape the new tests refuse would map to no position:
`bucket(4, a + 1)` under `[a]`, `bucket(4, s.x)` under `[s]`, `a + b` under
`[a, b]`, and the reduced `bucket(2, p.b + 1)` under `[p.b]`. The overlap test
in `areKeysCompatible`, `canCreatePartitioning` and `createPartitioning` would
then agree by construction, without `argumentsAreColumns` or the `refs.size ==
1` branch. Today three shape checks have to be kept in step by hand, and they
already differ: `refs.size == 1` here, the leaf match after `overExpression` in
`isExpressionCompatible`, and `AttributeReference` or `argumentsAreColumns` in
`canCreatePart
itioning`. Unlike the SPARK-59900 idea, this evaluates nothing new, so the
ANSI concern does not arise. The identity arm at L2207 and L2211 would have to
switch to `t.copy(children = Seq(a))` at the same time. Otherwise, with
`spark.sql.requireAllClusterKeysForCoPartition` off, a join keyed on `p.b + 1`
would give `bucket(4, p.b + 1)` a position, and `withReference` would build
`bucket(4, id + 1)`. I understand this may fit SPARK-59900 better, so please
take it as input.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -604,8 +604,15 @@ private[sql] object GroupPartitionsExec {
// `reduced` came from the one member
`checkKeyGroupCompatible` paired
// this side on, which need not be the member being
rewritten. The keys are
// reduced once, from the shared key rows, so `reduced`
describes them
- // whichever member this is, and only the key attribute is
re-targeted.
- reduced.withReference(expr.references.head)
+ // whichever member this is. Its argument is a bare column,
since the pairing
+ // admits no other. The member's own argument takes its
place, since that need
+ // not be one. A side shuffled onto this layout reports
`bucket(8, b + 1)` next
+ // to `bucket(8, id)`, or `b + 1` next to an identity `id`.
+ val argument = expr match {
+ case t: TransformExpression => t.children
+ case e => Seq(e)
+ }
+ reduced.copy(children = argument)
Review Comment:
Nit: this replaces the use that `TransformExpression.withReference` was
added for in SPARK-59045, but its Scaladoc (TransformExpression.scala:127-131)
still relies on the single leaf of `KeyedPartitioning.supportsExpressions` and
warns about field paths. TransformExpressionSuite.scala:115-116 also still says
the marker survives the rewrites that "`GroupPartitionsExec` apply". The only
callers left are the identity arm (partitioning.scala:2207 and
partitioning.scala:2211), which sees a transform over a single bare column.
Could the identity arm use `t.copy(children = Seq(a))` like this line, so that
`withReference` and its test can go? Otherwise, could the two comments be
updated?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -139,8 +139,10 @@ case class EnsureRequirements(
// OrderedDistribution requires grouped KeyedPartitioning with
sorted keys
// according to the distribution's ordering.
val satisfyingKeyedPartitioning = resolution.fold(identity, _._1)
- // The single-column invariant in
KeyedPartitioning.supportsExpressions guarantees
- // one attribute per partition expression.
+ // This binds the ordering to the partition expressions'
references, which is right
Review Comment:
Nit: this rewrites one of four copies of the single-column comment, and
L1128-L1129 rewrite another. The two in `reorderJoinKeysRecursively`, at
L534-L535 and L541-L542, still say "The single-column invariant in
KeyedPartitioning.supportsExpressions guarantees one attribute per partition
expression". The SPARK-59901 test and the broadcast expansion now reach that
code with `p.b + p.c`, where `flatMap(_.references)` returns two columns for
one expression. `reorder` then finds no order, or only permutes key pairs, so
nothing breaks. Could those two be reworded the same way?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/ShuffleSpecSuite.scala:
##########
@@ -761,6 +761,84 @@ class ShuffleSpecSuite extends SparkFunSuite with
SQLHelper {
}
}
+ test("SPARK-59887: a spec over a transform of an expression pairs with
nothing") {
+ // A join pairs its two sides up by the column each partition expression
references. Over
+ // `a = b` it would pair `bucket(4, a + 1)` with `bucket(4, b)`, and over
`s = b` it would pair
+ // `bucket(4, s.x)` with `bucket(4, b)`. A row with `a = b` sits in a
different bucket on each
+ // side.
+ val fn = new FakeBucket
+ val a = $"a".long
+ val b = $"b".long
+ val s = $"s".struct(new StructType().add("x", LongType))
+ // A spec clustered on the column its one partition expression references.
+ def keyed(expression: Expression): KeyedShuffleSpec =
+ KeyedShuffleSpec(
+ KeyedPartitioning(Seq(expression), Seq(InternalRow(0L),
InternalRow(1L))),
+ ClusteredDistribution(expression.references.toSeq))
+ def bucket(argument: Expression, numBuckets: Int): TransformExpression =
Review Comment:
Nit: this reverses the argument order of `bucket(numBuckets, expr)` at L676,
of the connector `Expressions.bucket(numBuckets, ...)`, and of the comments of
this test, e.g. L782 says `bucket(8, argument)` while L784 calls
`bucket(argument, 8)`. Also, the `s = b` example at L766-L767 joins a struct
with a long, which the analyzer rejects. The shape that can happen is a join of
two structs, `s = t`, which pairs `bucket(4, s.a)` with `bucket(4, t.b)`. Could
the helper take the bucket count first, and the comment use `s = t`?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -1520,6 +1534,203 @@ class KeyGroupedPartitioningSuite
}
}
+ // Each key is a function of `b` other than `b` itself. A check on the
leaves of the key would
+ // reject `b + 1` for its literal, but not `-b`, so both are tested.
+ Seq("p.b + 1" -> ((b: Long) => b + 1), "-p.b" -> ((b: Long) => -b)).foreach {
+ case (key, keyOf) =>
+ // `plain` holds `b` in 0..7, and `keys`, bucketed on `id`, holds the
key of each `b`.
+ def createKeyTables(): Unit = {
+ createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+ sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b =>
s"($b)").mkString(", "))
+ createTable("keys", Array(Column.create("id", LongType)),
Array(bucket(4, "id")))
+ sql("INSERT INTO testcat.ns.keys VALUES " +
+ (0 until 8).map(b => s"(${keyOf(b)})").mkString(", "))
+ }
+
+ test("SPARK-59887: a side shuffled onto a transform of an expression is
not paired as it " +
+ s"is ($key)") {
+ createKeyTables()
+ Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds =
8))
+
+ // The first join shuffles `plain` onto the bucket of its key. That is
a function of `b`,
+ // but not the same function of it as the third table's `bucket(n,
id)` is of `id`. The
+ // second join clusters `plain` on `b`. Pairing the two there, as they
stand or after
+ // reducing `bucket(8, id)` onto 4 buckets, puts a row with `b = id`
on a different
+ // partition on each side.
+ Seq(4 -> "false", 8 -> "true").foreach { case (third,
allowCompatibleTransforms) =>
+ withSQLConf(
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key ->
allowCompatibleTransforms) {
+ val df = sql(
+ s"""SELECT k.id, p.b, t.id FROM testcat.ns.keys k
+ |JOIN testcat.ns.plain p ON k.id = $key
+ |JOIN testcat.ns.bucket$third t ON p.b = t.id""".stripMargin)
+ checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong,
b.toLong)))
+ // One shuffle per join: `plain` for the first, the first join's
output for the
+ // second, and none for the third table. The second join does not
shuffle onto the
+ // bucket of the key, since that would move both of its sides.
+
assert(collectShuffles(stripAQEPlan(df.queryExecution.executedPlan)).size == 2)
+ }
+ }
+ }
+
+ test(s"SPARK-59887: a side is not shuffled onto a transform of an
expression ($key)") {
+ createKeyTables()
+ createTable("xy", Array(Column.create("x", LongType),
Column.create("y", LongType)),
+ Array.empty)
+ sql("INSERT INTO testcat.ns.xy VALUES " +
+ (0 until 8).map(b => s"(${keyOf(b)}, $b)").mkString(", "))
+
+ // The first join shuffles `plain` onto the bucket of its key, so its
output offers that
+ // layout next to `bucket(4, id)`. The second join must not shuffle
`xy` onto the first
+ // one. That builds `bucket(4, y)` for `xy`, not the bucket of the
same function of `y`. A
+ // row with `b = y` then lands in a different bucket on each side. The
join order decides
+ // which of the two layouts comes first, so both orders run.
+ withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+ Seq(
+ s"testcat.ns.plain p JOIN testcat.ns.keys k ON $key = k.id",
+ s"testcat.ns.keys k JOIN testcat.ns.plain p ON k.id =
$key").foreach { firstJoin =>
+ val df = sql(
+ s"""SELECT k.id, p.b, q.x FROM $firstJoin
+ |JOIN testcat.ns.xy q ON k.id = q.x AND p.b =
q.y""".stripMargin)
+ checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong,
keyOf(b))))
+ // `xy` is shuffled onto the usable layout, `bucket(4, x)`, and
never onto a bucket of
+ // `y`. So `plain` and `xy` are the only sides that move.
+ val shuffles =
collectShuffles(stripAQEPlan(df.queryExecution.executedPlan))
+ assert(shuffles.size == 2)
+
assert(!keyedPartitioningsOf(shuffles).exists(_.references.exists(_.name ==
"y")))
+ }
+ }
+ }
+
+ test("SPARK-59887: a broadcast join's layout over a join key is not
paired as it is " +
+ s"($key)") {
+ createKeyTables()
+ createBucketedIdTable("bucket4", 4, numIds = 8)
+
+ // An inner broadcast join reports the streamed side's layout over the
build side's join
+ // key too, so the first join also offers the bucket of `plain`'s key.
That needs no conf
+ // beyond the defaults. The second join clusters `plain` on `b`, so
pairing that layout with
+ // `bucket(4, id)` as it stands puts a row with `b = id` on a
different partition on each
+ // side.
+ val df = sql(
+ s"""SELECT /*+ BROADCAST(p) */ k.id, p.b, t.id FROM testcat.ns.keys k
+ |JOIN testcat.ns.plain p ON k.id = $key
+ |JOIN testcat.ns.bucket4 t ON p.b = t.id""".stripMargin)
+ checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong,
b.toLong)))
+ assert(collect(df.queryExecution.executedPlan) { case j:
BroadcastHashJoinExec => j }
+ .nonEmpty, "the first join is a broadcast join")
+ }
+ }
+
+ test("SPARK-59887: a layout over a transform of a struct field is not paired
nor shuffled onto") {
+ createBucketedIdTable("bucket4", 4, numIds = 8)
+ createStructTable()
+ // `l` shuffles `ps` onto `bucket(4, s.a)`, and `r` shuffles it onto
`bucket(4, s.b)`.
+ val l = "SELECT x.id, ps.s FROM testcat.ns.bucket4 x JOIN testcat.ns.ps ps
ON x.id = ps.s.a"
+ val r = "SELECT y.id, qs.s FROM testcat.ns.bucket4 y JOIN testcat.ns.ps qs
ON y.id = qs.s.b"
+
+ withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+ // Both layouts are functions of `s`, but not the same one, so the join
on `s` must not pair
+ // them as they stand. A row lands in bucket `a % 4` on one side and `b
% 4` on the other.
+ checkAnswer(
+ sql(s"SELECT l.id, r.id FROM ($l) l JOIN ($r) r ON l.s = r.s"),
+ (0 until 8).map(i => Row(i.toLong, (7 - i).toLong)))
+ // Nor may a join shuffle a plain `ps` onto `l`'s layout. That builds
`bucket(4, s)` for it,
+ // which drops the `.a` and hands the whole struct to a bucket function
over a long.
+ checkAnswer(
+ sql(s"SELECT l.id FROM ($l) l JOIN testcat.ns.ps r ON l.s = r.s"),
+ (0 until 8).map(i => Row(i.toLong)))
+ }
+ }
+
+ test("SPARK-59887: a reduce keeps what each member's keys are computed
from") {
+ Seq(4, 2).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 8))
+ createIdentityIdTable()
+ createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+ sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b =>
s"($b)").mkString(", "))
+ createStructTable()
+
+ withSQLConf(
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+ SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+ // The first join shuffles `plain` onto `bucket(4, b + 1)`, or onto `b +
1` for the
+ // identity-partitioned `ident`. The second join reduces the first one's
output onto
+ // `bucket(2)`, `plain`'s member included. That member is still over `b
+ 1`. So the third
+ // join, on the bare `b`, must neither pair it with `bucket(2, id)` nor
shuffle a plain
+ // side onto it.
+ for {
+ first <- Seq("bucket4", "ident")
+ (third, thirdKey) <- Seq("bucket2" -> "id", "plain" -> "b")
+ } {
+ val df = sql(
+ s"""SELECT f.id, p.b, t2.id, t3.$thirdKey FROM testcat.ns.$first f
+ |JOIN testcat.ns.plain p ON f.id = p.b + 1
+ |JOIN testcat.ns.bucket2 t2 ON f.id = t2.id
+ |JOIN testcat.ns.$third t3 ON p.b = t3.$thirdKey""".stripMargin)
+ checkAnswer(df, (0 until 7).map(b => Row(b + 1L, b.toLong, b + 1L,
b.toLong)))
+ }
+
+ // The same for a struct field. `ps`'s member is still over `s.a` after
the reduce, so the
+ // third join must not shuffle a plain `ps` onto it, which would build
`bucket(2, s)`.
+ val df = sql(
+ """SELECT x.id FROM testcat.ns.bucket4 x
+ |JOIN testcat.ns.ps ps ON x.id = ps.s.a
+ |JOIN testcat.ns.bucket2 t2 ON x.id = t2.id
+ |JOIN testcat.ns.ps r ON ps.s = r.s""".stripMargin)
+ checkAnswer(df, (0 until 8).map(i => Row(i.toLong)))
+ }
+ }
+
+ test("SPARK-59887: an identity side is not reduced onto a transform of an
expression") {
+ createBucketedIdTable("bucket4", 4, numIds = 8)
+ createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+ sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 7).map(b =>
s"($b)").mkString(", "))
Review Comment:
Nit: `plain` is created inline three times in the new tests (L1543-L1544,
L1650-L1651 and here), and only this copy inserts `0 until 7`, with no comment
saying why. The expected rows at L1703 stay the same with `0 until 8`, since
`p.b + 1 = 8` is not in `bucket4`. How about a `createPlainTable(bs: Seq[Long]
= 0L until 8L)` next to `createIdentityIdTable`? Also, the two new helpers sit
between `createBucketedIdTable` (L740) and its wrapper `createBucketedIdTables`
(L764), which were adjacent before. Could they move after that group?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -367,12 +369,14 @@ case class EnsureRequirements(
val shouldConsiderMinParallelism = children.zip(specs).forall { case
(child, spec) =>
spec.forall(!_.canCreatePartitioning) ||
child.isInstanceOf[ShuffleExchangeLike]
}
- // Choose all the specs that can be used to shuffle other children
+ // Choose all the specs that can be used to shuffle other children,
keeping only the members
+ // that can serve as the layout. Any other member would build a
partitioning its own child is
+ // not laid out on.
val candidateSpecs = children.zip(specs).collect {
case (child, Some(spec)) if spec.canCreatePartitioning &&
Review Comment:
Nit: `spec.canCreatePartitioning` here is the same as the member list below
being non-empty, since a collection now answers `exists` over its members. So
one decision has two sources, and the `.max` at L391 depends on that identity
in partitioning.scala. If `ShuffleSpecCollection.canCreatePartitioning`
changes, the `.max` throws on an empty list, which the SPARK-59256 test comment
warns about. Could this drop the guard and keep the children whose `members`
are non-empty instead? The comments at L385-L386 ("the best any member offers")
and L405 ("over all of its members") could then say serving members.
--
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]