dongjoon-hyun commented on code in PR #59636:
URL: https://github.com/apache/spark/pull/59636#discussion_r4464269545
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoin.scala:
##########
@@ -40,27 +40,32 @@ import
org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, BroadcastNes
object CoalesceBucketsInJoin extends Rule[SparkPlan] {
private def updateNumCoalescedBucketsInScan(
plan: SparkPlan,
+ targetScan: FileSourceScanExec,
numCoalescedBuckets: Int): SparkPlan = {
+ // Only the scan whose partitioning reaches this join. A broadcast build
+ // scan in the same subtree keeps its own bucket count.
plan transformUp {
Review Comment:
Minor: this still runs `transformUp` over the whole coalesced side and finds
the target by reference (`f eq targetScan`), instead of rewriting along the
path that `findBucketedScan` walked, so every occurrence of that instance is
stamped. If the same `FileSourceScanExec` instance appeared twice in the
coalesced side, e.g. once on the streamed path and once inside a bucketed
sort-merge join under a broadcast build side, both copies would get
`Some(numCoalescedBuckets)`, which brings back the nested-join corruption this
PR fixes. The planner creates a new scan instance for each logical node, so I
do not think this happens today. It would take a hand-built plan or a later
rule that shares subtrees by reference. Could the rewrite follow the same
`FilterExec`, `ProjectExec` and streamed-child path as `findBucketedScan`,
rebuilding each node with `withNewChildren`, so that it stamps exactly the
walked leaf? If that is too much code for this PR, could the comment at L45-46
at least say t
hat it relies on the scan instance not being shared?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoin.scala:
##########
@@ -111,26 +116,35 @@ object CoalesceBucketsInJoin extends Rule[SparkPlan] {
* and numbers of buckets are not equal but divisible.
Review Comment:
Minor: this scaladoc still describes the old extractor. It does not say that
`unapply` now also returns the bucketed scan of each side, which is the only
scan the rule may stamp and the invariant this PR relies on. Also, "are
consisted of only the scan operation" did not match the walk through filters,
projects and broadcast joins even before this PR. Could L112-117 say something
like this?
```scala
/**
* An extractor that extracts `SortMergeJoinExec` and `ShuffledHashJoin`
whose sides each
* reach a bucketed scan through filters, projects, and the streamed child
of a broadcast
* join, and whose numbers of buckets are not equal but divisible. It
returns the join, the
* bucketed scan of each side, which is the only scan the caller may
coalesce, and the two
* numbers of buckets.
*/
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +264,161 @@ class CoalesceBucketsInJoinSuite extends
SharedSparkSession {
}
}
+ test("SPARK-60382: broadcast join coalescing uses the streamed side bucket
count") {
+ withSQLConf(SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ def scan(cols: Seq[Attribute], numBuckets: Int): FileSourceScanExec = {
+ val relation = HadoopFsRelation(
+ location = new InMemoryFileIndex(spark, Nil, Map.empty, None),
+ partitionSchema = PartitionSpec.emptySpec.partitionColumns,
+ dataSchema = DataTypeUtils.fromAttributes(cols),
+ bucketSpec = Some(BucketSpec(numBuckets, Seq("i"), Nil)),
+ fileFormat = new ParquetFileFormat(),
+ options = Map.empty)(spark)
+ FileSourceScanExec(
+ relation, None, cols, relation.dataSchema, Nil, None, None, Nil,
None)
+ }
+
+ def cols(): Seq[Attribute] = Seq(
+ AttributeReference("i", IntegerType)(),
+ AttributeReference("k", IntegerType)())
+
+ def coalesced(plan: SparkPlan): Seq[(Int, Option[Int])] = {
+ plan.collect { case f: FileSourceScanExec =>
+ (f.relation.bucketSpec.get.numBuckets, f.optionalNumCoalescedBuckets)
+ }
+ }
+
+ // Streamed side is 100 buckets. 100 % 8 != 0, so nothing is coalesced,
+ // including the 16-bucket build scan that sits on the left.
+ val build = cols()
+ val stream = cols()
+ val other = Seq(AttributeReference("i", IntegerType)())
+ val hashJoin = BroadcastHashJoinExec(
+ Seq(build(1)), Seq(stream(1)), Inner, BuildLeft, None,
+ scan(build, 16), scan(stream, 100))
+ val plan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, hashJoin, scan(other,
8)))
+ assert(coalesced(plan) == Seq((16, None), (100, None), (8, None)))
+
+ val nestedLoop = BroadcastNestedLoopJoinExec(
+ scan(build, 16), scan(stream, 100), BuildLeft, Inner, None)
+ val nestedPlan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, nestedLoop,
scan(other, 8)))
+ assert(coalesced(nestedPlan) == Seq((16, None), (100, None), (8, None)))
+
+ // Streamed side is 16 buckets and the other input is 8. Only the
+ // streamed scan is rewritten to 8. The 100-bucket build scan is not.
+ val buildDiv = cols()
+ val streamDiv = cols()
+ val otherDiv = Seq(AttributeReference("i", IntegerType)())
+ val hashJoinDiv = BroadcastHashJoinExec(
+ Seq(buildDiv(1)), Seq(streamDiv(1)), Inner, BuildLeft, None,
+ scan(buildDiv, 100), scan(streamDiv, 16))
+ val planDiv = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(streamDiv.head), Seq(otherDiv.head), Inner, None,
+ hashJoinDiv, scan(otherDiv, 8)))
+ assert(coalesced(planDiv) == Seq((100, None), (16, Some(8)), (8, None)))
+
+ val nestedLoopDiv = BroadcastNestedLoopJoinExec(
+ scan(buildDiv, 100), scan(streamDiv, 16), BuildLeft, Inner, None)
+ val nestedPlanDiv = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(streamDiv.head), Seq(otherDiv.head), Inner, None,
+ nestedLoopDiv, scan(otherDiv, 8)))
+ assert(coalesced(nestedPlanDiv) == Seq((100, None), (16, Some(8)), (8,
None)))
+ }
+ }
+
+ test("SPARK-60382: a bucketed join inside a broadcast build side keeps its
own bucket count") {
+ withSQLConf(SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ def scan(cols: Seq[Attribute], numBuckets: Int): FileSourceScanExec = {
+ val relation = HadoopFsRelation(
+ location = new InMemoryFileIndex(spark, Nil, Map.empty, None),
+ partitionSchema = PartitionSpec.emptySpec.partitionColumns,
+ dataSchema = DataTypeUtils.fromAttributes(cols),
+ bucketSpec = Some(BucketSpec(numBuckets, Seq("i"), Nil)),
+ fileFormat = new ParquetFileFormat(),
+ options = Map.empty)(spark)
+ FileSourceScanExec(
+ relation, None, cols, relation.dataSchema, Nil, None, None, Nil,
None)
+ }
+
+ def coalesced(plan: SparkPlan): Seq[(Int, Option[Int])] = {
+ plan.collect { case f: FileSourceScanExec =>
+ (f.relation.bucketSpec.get.numBuckets, f.optionalNumCoalescedBuckets)
+ }
+ }
+
+ // SMJ(s.i = o.i)(BHJ(BuildRight, s16, SMJ(a.i = c.i)(a100, c8)), o8).
The outer join
+ // coalesces s16 (the streamed side of the inner broadcast join) to 8.
The inner
+ // sort-merge join is then judged on its own: 100 is not divisible by 8,
so a100 and
+ // c8 are left untouched even though they sit inside the outer join's
coalesced side.
+ val s = Seq(
+ AttributeReference("i", IntegerType)(), AttributeReference("k",
IntegerType)())
+ val a = Seq(
+ AttributeReference("i", IntegerType)(), AttributeReference("k",
IntegerType)())
+ val c = Seq(AttributeReference("i", IntegerType)())
+ val o = Seq(AttributeReference("i", IntegerType)())
+
+ val inner = SortMergeJoinExec(
+ Seq(a.head), Seq(c.head), Inner, None, scan(a, 100), scan(c, 8))
+ val bhj = BroadcastHashJoinExec(
+ Seq(s(1)), Seq(a(1)), Inner, BuildRight, None, scan(s, 16), inner)
Review Comment:
Minor, following up on my item 1: this covers a nested bucketed join only
under a `BuildRight` broadcast, while the description now says the corruption
happens "on either build side". Under `BuildLeft`, the base reads the count
from the nested join, because `collectFirst` reaches its left scan first. For
example, in `SMJ(BHJ(BuildLeft, SMJ(a16, c12), s16), o8)` the base reads 16
from a16, accepts 16 vs 8, and stamps a16, c12 and s16 with `Some(8)`. Since 12
% 8 != 0, the inner join then runs without a shuffle and drops rows, while this
PR stamps only s16. I traced this by reading the code and did not run it. Could
this test add that case?
```scala
val innerLeft = SortMergeJoinExec(
Seq(a.head), Seq(c.head), Inner, None, scan(a, 16), scan(c, 12))
val bhjLeft = BroadcastHashJoinExec(
Seq(a(1)), Seq(s(1)), Inner, BuildLeft, None, innerLeft, scan(s, 16))
val planLeft = CoalesceBucketsInJoin(SortMergeJoinExec(
Seq(s.head), Seq(o.head), Inner, None, bhjLeft, scan(o, 8)))
assert(coalesced(planLeft) == Seq((16, None), (12, None), (16, Some(8)), (8,
None)))
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +264,161 @@ class CoalesceBucketsInJoinSuite extends
SharedSparkSession {
}
}
+ test("SPARK-60382: broadcast join coalescing uses the streamed side bucket
count") {
+ withSQLConf(SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ def scan(cols: Seq[Attribute], numBuckets: Int): FileSourceScanExec = {
+ val relation = HadoopFsRelation(
+ location = new InMemoryFileIndex(spark, Nil, Map.empty, None),
+ partitionSchema = PartitionSpec.emptySpec.partitionColumns,
+ dataSchema = DataTypeUtils.fromAttributes(cols),
+ bucketSpec = Some(BucketSpec(numBuckets, Seq("i"), Nil)),
+ fileFormat = new ParquetFileFormat(),
+ options = Map.empty)(spark)
+ FileSourceScanExec(
+ relation, None, cols, relation.dataSchema, Nil, None, None, Nil,
None)
+ }
+
+ def cols(): Seq[Attribute] = Seq(
+ AttributeReference("i", IntegerType)(),
+ AttributeReference("k", IntegerType)())
+
+ def coalesced(plan: SparkPlan): Seq[(Int, Option[Int])] = {
+ plan.collect { case f: FileSourceScanExec =>
+ (f.relation.bucketSpec.get.numBuckets, f.optionalNumCoalescedBuckets)
+ }
+ }
+
+ // Streamed side is 100 buckets. 100 % 8 != 0, so nothing is coalesced,
+ // including the 16-bucket build scan that sits on the left.
+ val build = cols()
+ val stream = cols()
+ val other = Seq(AttributeReference("i", IntegerType)())
+ val hashJoin = BroadcastHashJoinExec(
+ Seq(build(1)), Seq(stream(1)), Inner, BuildLeft, None,
+ scan(build, 16), scan(stream, 100))
+ val plan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, hashJoin, scan(other,
8)))
+ assert(coalesced(plan) == Seq((16, None), (100, None), (8, None)))
+
+ val nestedLoop = BroadcastNestedLoopJoinExec(
+ scan(build, 16), scan(stream, 100), BuildLeft, Inner, None)
+ val nestedPlan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, nestedLoop,
scan(other, 8)))
+ assert(coalesced(nestedPlan) == Seq((16, None), (100, None), (8, None)))
+
+ // Streamed side is 16 buckets and the other input is 8. Only the
+ // streamed scan is rewritten to 8. The 100-bucket build scan is not.
+ val buildDiv = cols()
+ val streamDiv = cols()
+ val otherDiv = Seq(AttributeReference("i", IntegerType)())
+ val hashJoinDiv = BroadcastHashJoinExec(
+ Seq(buildDiv(1)), Seq(streamDiv(1)), Inner, BuildLeft, None,
+ scan(buildDiv, 100), scan(streamDiv, 16))
+ val planDiv = CoalesceBucketsInJoin(SortMergeJoinExec(
Review Comment:
Nit: every new plan-level case puts a `SortMergeJoinExec` over the broadcast
join, so no case covers a `ShuffledHashJoinExec` parent, where
`isCoalesceSHJStreamSide` (CoalesceBucketsInJoin.scala:103) now gets the
streamed scan's count. For example, over `hashJoinDiv` (a `BuildLeft` join of a
100-bucket build scan and a 16-bucket streamed scan) and an 8-bucket scan, a
shuffled hash join should coalesce the streamed scan to 8 when it builds on the
right, and change nothing when it builds on the left. The base coalesces
nothing in either case, because it reads 100. Could this test add those two
cases?
```scala
val shj = CoalesceBucketsInJoin(ShuffledHashJoinExec(
Seq(streamDiv.head), Seq(otherDiv.head), Inner, BuildRight, None,
hashJoinDiv, scan(otherDiv, 8)))
assert(coalesced(shj) == Seq((100, None), (16, Some(8)), (8, None)))
```
With `BuildLeft`, the expected value is `Seq((100, None), (16, None), (8,
None))`.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -17,20 +17,22 @@
package org.apache.spark.sql.execution.bucketing
+import org.apache.spark.sql.Row
import org.apache.spark.sql.catalyst.catalog.BucketSpec
import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference}
import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight,
BuildSide}
import org.apache.spark.sql.catalyst.plans.Inner
import org.apache.spark.sql.catalyst.types.DataTypeUtils
import org.apache.spark.sql.execution.{BinaryExecNode, FileSourceScanExec,
SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
import org.apache.spark.sql.execution.datasources.{HadoopFsRelation,
InMemoryFileIndex, PartitionSpec}
import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
-import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec,
ShuffledHashJoinExec, SortMergeJoinExec}
+import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec,
BroadcastNestedLoopJoinExec, ShuffledHashJoinExec, SortMergeJoinExec}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
import org.apache.spark.sql.types.IntegerType
-class CoalesceBucketsInJoinSuite extends SharedSparkSession {
+class CoalesceBucketsInJoinSuite extends SharedSparkSession with
AdaptiveSparkPlanHelper {
Review Comment:
Nit: since this line changes anyway and the suite now calls `checkAnswer`,
could it mix in `QueryTest` explicitly, with `import
org.apache.spark.sql.{QueryTest, Row}` at L20? `SharedSparkSession` already
extends `QueryTest`, so nothing changes at runtime, but AGENTS.md:61 asks for
the explicit mix-in. The one-line form would be 105 characters, so it needs a
break:
```scala
class CoalesceBucketsInJoinSuite extends QueryTest with SharedSparkSession
with AdaptiveSparkPlanHelper {
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +264,161 @@ class CoalesceBucketsInJoinSuite extends
SharedSparkSession {
}
}
+ test("SPARK-60382: broadcast join coalescing uses the streamed side bucket
count") {
+ withSQLConf(SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ def scan(cols: Seq[Attribute], numBuckets: Int): FileSourceScanExec = {
+ val relation = HadoopFsRelation(
+ location = new InMemoryFileIndex(spark, Nil, Map.empty, None),
+ partitionSchema = PartitionSpec.emptySpec.partitionColumns,
+ dataSchema = DataTypeUtils.fromAttributes(cols),
+ bucketSpec = Some(BucketSpec(numBuckets, Seq("i"), Nil)),
+ fileFormat = new ParquetFileFormat(),
+ options = Map.empty)(spark)
+ FileSourceScanExec(
+ relation, None, cols, relation.dataSchema, Nil, None, None, Nil,
None)
+ }
+
+ def cols(): Seq[Attribute] = Seq(
+ AttributeReference("i", IntegerType)(),
+ AttributeReference("k", IntegerType)())
+
+ def coalesced(plan: SparkPlan): Seq[(Int, Option[Int])] = {
+ plan.collect { case f: FileSourceScanExec =>
+ (f.relation.bucketSpec.get.numBuckets, f.optionalNumCoalescedBuckets)
+ }
+ }
+
+ // Streamed side is 100 buckets. 100 % 8 != 0, so nothing is coalesced,
+ // including the 16-bucket build scan that sits on the left.
+ val build = cols()
+ val stream = cols()
+ val other = Seq(AttributeReference("i", IntegerType)())
+ val hashJoin = BroadcastHashJoinExec(
+ Seq(build(1)), Seq(stream(1)), Inner, BuildLeft, None,
+ scan(build, 16), scan(stream, 100))
+ val plan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, hashJoin, scan(other,
8)))
+ assert(coalesced(plan) == Seq((16, None), (100, None), (8, None)))
+
+ val nestedLoop = BroadcastNestedLoopJoinExec(
+ scan(build, 16), scan(stream, 100), BuildLeft, Inner, None)
+ val nestedPlan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, nestedLoop,
scan(other, 8)))
+ assert(coalesced(nestedPlan) == Seq((16, None), (100, None), (8, None)))
+
+ // Streamed side is 16 buckets and the other input is 8. Only the
+ // streamed scan is rewritten to 8. The 100-bucket build scan is not.
+ val buildDiv = cols()
+ val streamDiv = cols()
+ val otherDiv = Seq(AttributeReference("i", IntegerType)())
+ val hashJoinDiv = BroadcastHashJoinExec(
+ Seq(buildDiv(1)), Seq(streamDiv(1)), Inner, BuildLeft, None,
+ scan(buildDiv, 100), scan(streamDiv, 16))
+ val planDiv = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(streamDiv.head), Seq(otherDiv.head), Inner, None,
+ hashJoinDiv, scan(otherDiv, 8)))
+ assert(coalesced(planDiv) == Seq((100, None), (16, Some(8)), (8, None)))
+
+ val nestedLoopDiv = BroadcastNestedLoopJoinExec(
+ scan(buildDiv, 100), scan(streamDiv, 16), BuildLeft, Inner, None)
+ val nestedPlanDiv = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(streamDiv.head), Seq(otherDiv.head), Inner, None,
+ nestedLoopDiv, scan(otherDiv, 8)))
+ assert(coalesced(nestedPlanDiv) == Seq((100, None), (16, Some(8)), (8,
None)))
+ }
+ }
+
+ test("SPARK-60382: a bucketed join inside a broadcast build side keeps its
own bucket count") {
+ withSQLConf(SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ def scan(cols: Seq[Attribute], numBuckets: Int): FileSourceScanExec = {
Review Comment:
Nit: this `scan` and the `coalesced` helper at L345-349 are line-for-line
copies of the ones in the previous test (L269-279 and L285-289), so the file
now has three copies of this `FileSourceScanExec` construction, counting
`newFileSourceScanExec` at L70-80. This adds to my item 12, which you left for
a follow-up, but this second copy is new in 081b027. Could the two helpers at
least move to private suite-level methods that both tests share?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +264,161 @@ class CoalesceBucketsInJoinSuite extends
SharedSparkSession {
}
}
+ test("SPARK-60382: broadcast join coalescing uses the streamed side bucket
count") {
+ withSQLConf(SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ def scan(cols: Seq[Attribute], numBuckets: Int): FileSourceScanExec = {
+ val relation = HadoopFsRelation(
+ location = new InMemoryFileIndex(spark, Nil, Map.empty, None),
+ partitionSchema = PartitionSpec.emptySpec.partitionColumns,
+ dataSchema = DataTypeUtils.fromAttributes(cols),
+ bucketSpec = Some(BucketSpec(numBuckets, Seq("i"), Nil)),
+ fileFormat = new ParquetFileFormat(),
+ options = Map.empty)(spark)
+ FileSourceScanExec(
+ relation, None, cols, relation.dataSchema, Nil, None, None, Nil,
None)
+ }
+
+ def cols(): Seq[Attribute] = Seq(
+ AttributeReference("i", IntegerType)(),
+ AttributeReference("k", IntegerType)())
+
+ def coalesced(plan: SparkPlan): Seq[(Int, Option[Int])] = {
+ plan.collect { case f: FileSourceScanExec =>
+ (f.relation.bucketSpec.get.numBuckets, f.optionalNumCoalescedBuckets)
+ }
+ }
+
+ // Streamed side is 100 buckets. 100 % 8 != 0, so nothing is coalesced,
+ // including the 16-bucket build scan that sits on the left.
+ val build = cols()
+ val stream = cols()
+ val other = Seq(AttributeReference("i", IntegerType)())
+ val hashJoin = BroadcastHashJoinExec(
+ Seq(build(1)), Seq(stream(1)), Inner, BuildLeft, None,
+ scan(build, 16), scan(stream, 100))
+ val plan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, hashJoin, scan(other,
8)))
+ assert(coalesced(plan) == Seq((16, None), (100, None), (8, None)))
+
+ val nestedLoop = BroadcastNestedLoopJoinExec(
+ scan(build, 16), scan(stream, 100), BuildLeft, Inner, None)
+ val nestedPlan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(stream.head), Seq(other.head), Inner, None, nestedLoop,
scan(other, 8)))
+ assert(coalesced(nestedPlan) == Seq((16, None), (100, None), (8, None)))
+
+ // Streamed side is 16 buckets and the other input is 8. Only the
+ // streamed scan is rewritten to 8. The 100-bucket build scan is not.
+ val buildDiv = cols()
+ val streamDiv = cols()
+ val otherDiv = Seq(AttributeReference("i", IntegerType)())
+ val hashJoinDiv = BroadcastHashJoinExec(
+ Seq(buildDiv(1)), Seq(streamDiv(1)), Inner, BuildLeft, None,
+ scan(buildDiv, 100), scan(streamDiv, 16))
+ val planDiv = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(streamDiv.head), Seq(otherDiv.head), Inner, None,
+ hashJoinDiv, scan(otherDiv, 8)))
+ assert(coalesced(planDiv) == Seq((100, None), (16, Some(8)), (8, None)))
+
+ val nestedLoopDiv = BroadcastNestedLoopJoinExec(
+ scan(buildDiv, 100), scan(streamDiv, 16), BuildLeft, Inner, None)
+ val nestedPlanDiv = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(streamDiv.head), Seq(otherDiv.head), Inner, None,
+ nestedLoopDiv, scan(otherDiv, 8)))
+ assert(coalesced(nestedPlanDiv) == Seq((100, None), (16, Some(8)), (8,
None)))
+ }
+ }
+
+ test("SPARK-60382: a bucketed join inside a broadcast build side keeps its
own bucket count") {
+ withSQLConf(SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ def scan(cols: Seq[Attribute], numBuckets: Int): FileSourceScanExec = {
+ val relation = HadoopFsRelation(
+ location = new InMemoryFileIndex(spark, Nil, Map.empty, None),
+ partitionSchema = PartitionSpec.emptySpec.partitionColumns,
+ dataSchema = DataTypeUtils.fromAttributes(cols),
+ bucketSpec = Some(BucketSpec(numBuckets, Seq("i"), Nil)),
+ fileFormat = new ParquetFileFormat(),
+ options = Map.empty)(spark)
+ FileSourceScanExec(
+ relation, None, cols, relation.dataSchema, Nil, None, None, Nil,
None)
+ }
+
+ def coalesced(plan: SparkPlan): Seq[(Int, Option[Int])] = {
+ plan.collect { case f: FileSourceScanExec =>
+ (f.relation.bucketSpec.get.numBuckets, f.optionalNumCoalescedBuckets)
+ }
+ }
+
+ // SMJ(s.i = o.i)(BHJ(BuildRight, s16, SMJ(a.i = c.i)(a100, c8)), o8).
The outer join
+ // coalesces s16 (the streamed side of the inner broadcast join) to 8.
The inner
+ // sort-merge join is then judged on its own: 100 is not divisible by 8,
so a100 and
+ // c8 are left untouched even though they sit inside the outer join's
coalesced side.
+ val s = Seq(
+ AttributeReference("i", IntegerType)(), AttributeReference("k",
IntegerType)())
+ val a = Seq(
+ AttributeReference("i", IntegerType)(), AttributeReference("k",
IntegerType)())
+ val c = Seq(AttributeReference("i", IntegerType)())
+ val o = Seq(AttributeReference("i", IntegerType)())
+
+ val inner = SortMergeJoinExec(
+ Seq(a.head), Seq(c.head), Inner, None, scan(a, 100), scan(c, 8))
+ val bhj = BroadcastHashJoinExec(
+ Seq(s(1)), Seq(a(1)), Inner, BuildRight, None, scan(s, 16), inner)
+ val plan = CoalesceBucketsInJoin(SortMergeJoinExec(
+ Seq(s.head), Seq(o.head), Inner, None, bhj, scan(o, 8)))
+ assert(coalesced(plan) == Seq((16, Some(8)), (100, None), (8, None), (8,
None)))
+ }
+ }
+
+ test("SPARK-60382: broadcast hash join bucket coalescing keeps the
sort-merge match") {
+ Seq(true, false).foreach { aqeEnabled =>
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqeEnabled.toString,
+ SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true") {
+ withTable("dim16", "fact100", "other8") {
+ withTempView("picked") {
+ spark.sql(
+ """CREATE TABLE dim16 (i INT, k INT) USING parquet
+ |CLUSTERED BY (i) INTO 16 BUCKETS""".stripMargin)
+ spark.sql(
+ """CREATE TABLE fact100 (i INT, k INT, v STRING) USING parquet
+ |CLUSTERED BY (i) INTO 100 BUCKETS""".stripMargin)
+ spark.sql(
+ """CREATE TABLE other8 (i INT, v STRING) USING parquet
+ |CLUSTERED BY (i) INTO 8 BUCKETS""".stripMargin)
+ spark.sql("INSERT INTO dim16 VALUES (0, 1)")
+ spark.sql(
+ """CREATE OR REPLACE TEMP VIEW picked AS
+ |SELECT CAST(id AS INT) AS i
+ |FROM range(0, 4000)
+ |WHERE pmod(pmod(hash(CAST(id AS INT)), 100), 8)
+ | <> pmod(hash(CAST(id AS INT)), 8)
+ |ORDER BY id
+ |LIMIT 1""".stripMargin)
+ spark.sql("INSERT INTO fact100 SELECT i, 1, 'fact' FROM picked")
+ spark.sql("INSERT INTO other8 SELECT i, 'other' FROM picked")
+ val key = spark.sql("SELECT i FROM picked").head().getInt(0)
+
+ val query =
+ """SELECT /*+ BROADCAST(dim16), MERGE(fact100, other8) */
+ | fact100.i, fact100.v, other8.v
+ |FROM dim16
+ |JOIN fact100 ON dim16.k = fact100.k
+ |JOIN other8 ON fact100.i = other8.i""".stripMargin
+ val df = spark.sql(query)
+ val executed = df.queryExecution.executedPlan
Review Comment:
Minor: with AQE on, `executed` is an `AdaptiveSparkPlanExec` that has not
run yet, so `collect(executed)` walks its initial plan (`executedPlan` returns
`currentPhysicalPlan`, AdaptiveSparkPlanExec.scala:302), and these two asserts
check only the rule's first decision. `CoalesceBucketsInJoin` runs again in
`reOptimize` (AdaptiveSparkPlanExec.scala:1150) after the dim16 broadcast stage
materializes, which is where AQE kept the base code's wrong 0-shuffle plan
(item 2), but nothing here pins the plan that AQE finally runs. `checkAnswer`
at L415 runs this same `df`, since `df.collect()` uses `df.queryExecution`, so
afterwards `executedPlan` holds the final plan. Could `checkAnswer` move above
L408, so that these asserts check the plan that produced the answer? I traced
this by reading the code and did not run it.
--
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]