dongjoon-hyun commented on code in PR #59636:
URL: https://github.com/apache/spark/pull/59636#discussion_r4371926421
##########
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 {
- case f: FileSourceScanExec if f.relation.bucketSpec.nonEmpty =>
+ case f: FileSourceScanExec if f eq targetScan =>
Review Comment:
**This narrowing also fixes a second wrong-result shape that needs no
`BuildLeft`, but the description and the JIRA scope the bug to `BuildLeft`
children, and no test covers it.** The base stamped every bucketed scan of the
coalesced side (`case f: FileSourceScanExec if
f.relation.bucketSpec.nonEmpty`), including scans inside a broadcast build
side. For example, take `SMJ(s.i = o.i)(BHJ(BuildRight, s16, SMJ(a.i =
c.i)(a100, c8)), o8)`:
- `collectFirst` correctly finds the streamed s16, so the left side is
coalesced from 16 to 8.
- The base `transformUp` also stamps a100 and c8 inside the build side with
`Some(8)`.
- When the top-down pass reaches the inner join, the old `getBucketSpec`
skips the stamped scans, so that join makes no decision of its own.
- a100 then reports `HashPartitioning(i, 8)` while it groups its files by
`bucketId % 8`, and 100 % 8 != 0. EnsureRequirements adds no shuffle for the
inner join, so the broadcast relation loses the rows whose keys land in
different partitions.
With this PR only s16 is stamped, and the inner join is judged on its own
(100 vs 8 is not divisible). So a bucketed join under a `BuildRight` broadcast
is affected too, which matters for the JIRA and the release note. I traced this
by reading the code and did not run it. Could the description and the JIRA also
cover bucketed scans inside a broadcast build side, on either build side, and
could the plan-level test add a case like this?
```scala
val s = cols()
val a = cols()
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 p = CoalesceBucketsInJoin(SortMergeJoinExec(
Seq(s.head), Seq(o.head), Inner, None, bhj, scan(o, 8)))
assert(coalesced(p) == Seq((16, Some(8)), (100, None), (8, None), (8, None)))
```
The base returns `(100, Some(8))` and `(8, Some(8))` for the inner pair.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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(
Review Comment:
Minor: this is the only plan-level case that rewrites a scan, and it uses a
broadcast hash join, while the PR description says the divisible 16-to-8 case
is checked "for both broadcast hash and broadcast nested-loop joins". The
nested-loop join at L302-306 covers only the 100-vs-8 case, where nothing is
rewritten. A nested-loop rewrite through `BuildRight` is still pinned by
BucketedReadSuite.scala:1067-1073, so this is a coverage gap rather than an
untested branch. Could this add the nested-loop variant, e.g.
`BroadcastNestedLoopJoinExec(scan(buildDiv, 100), scan(streamDiv, 16),
BuildLeft, Inner, None)` under the same sort-merge join with the expected
`Seq((100, None), (16, Some(8)), (8, None))`, or could the description sentence
be narrowed?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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)))
+ }
+ }
+
+ test("SPARK-60382: broadcast hash join bucket coalescing keeps the
sort-merge match") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
Review Comment:
**This pins AQE off, so the default AQE path has no end-to-end coverage,
although the base bug reproduces there too.** `CoalesceBucketsInJoin` is also
the first rule of `queryStagePreparationRules`
(AdaptiveSparkPlanExec.scala:145). With AQE on, the base code stamps fact100
with 8 in the initial plan, so EnsureRequirements adds no shuffle under the
sort-merge join. After the dim16 broadcast stage materializes, the re-planned
join has a `BroadcastQueryStageExec` leaf on its build side, so the base
`collectFirst` finds 100 and the new plan needs a shuffle. AQE keeps the
current plan unless the new one is cheaper (AdaptiveSparkPlanExec.scala:451),
so the 0-shuffle wrong plan runs and the query returns no rows. I traced this
by reading the code and did not run it. Could this test loop over `Seq(true,
false)` for AQE as BucketedReadSuite.scala:1031 does, and mix in
`AdaptiveSparkPlanHelper` so that `collect(executed)` sees the scans inside
`AdaptiveSparkPlanExec`?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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(
Review Comment:
Minor: this test does not carry over to branch-3.5 as it is, although the
bug has been there since 3.5.0 (SPARK-43107). On branch-3.5:
- `FileSourceScanExec` has no `stream` parameter, so
`FileSourceScanExec(relation, None, cols, ...)` does not compile.
- `satisfiesOutputPartitioning` has no `PartitioningCollection` case,
because SPARK-43228 is only in 4.0.0 and later, while an inner
`BroadcastHashJoinExec` always reports a `PartitioningCollection` there
(BroadcastHashJoinExec.scala:76 and :115 on branch-3.5). With the arity fixed,
`planDiv` at L319 fails even with the fix, and the first case at L300 passes
even without it.
- The nested-loop case and the end-to-end test still tell the base and the
fix apart, because a `ProjectExec` unwraps a one-element collection there
(AliasAwareOutputExpression.scala:58 on branch-3.5).
I checked this by reading branch-3.5 and did not run it. The rule file is
unchanged on branch-4.0 and later, so only a 3.5 backport is affected. Could
the broadcast hash join cases put a `ProjectExec` over the broadcast join, or
set `spark.sql.execution.broadcastHashJoin.outputPartitioningExpandLimit` to 0,
so that the same test works on branch-3.5?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoin.scala:
##########
@@ -111,26 +116,34 @@ object CoalesceBucketsInJoin extends Rule[SparkPlan] {
* and numbers of buckets are not equal but divisible.
*/
object ExtractJoinWithBuckets {
- @tailrec
- private def hasScanOperation(plan: SparkPlan): Boolean = plan match {
- case f: FilterExec => hasScanOperation(f.child)
- case p: ProjectExec => hasScanOperation(p.child)
- case j: BroadcastHashJoinExec =>
- if (j.buildSide == BuildLeft) hasScanOperation(j.right) else
hasScanOperation(j.left)
- case j: BroadcastNestedLoopJoinExec =>
- if (j.buildSide == BuildLeft) hasScanOperation(j.right) else
hasScanOperation(j.left)
- case f: FileSourceScanExec => f.relation.bucketSpec.nonEmpty
- case _ => false
+ private def streamedSide(
Review Comment:
Nit: the base already used a one-line form for these two cases (`if
(j.buildSide == BuildLeft) hasScanOperation(j.right) else
hasScanOperation(j.left)`). The helper does not let the two cases merge, since
no common trait exposes `buildSide`, and both branches of the inline form are
tail calls, so `@tailrec` still holds. Could L135 and L137 be `if (j.buildSide
== BuildLeft) findBucketedScan(j.right) else findBucketedScan(j.left)`,
dropping `streamedSide` and the `BuildSide` import at L24?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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 = {
Review Comment:
Nit: this copies `newFileSourceScanExec` (L69-79) line for line except for
the bucket columns (`Seq("i")` instead of all columns). That constructor call
has already needed two edits in the helper (SPARK-44475 and SPARK-50144), and
the next one would have to be made in both places. Could
`newFileSourceScanExec` take an optional list of bucket columns, or could these
cases use single-column `RelationSetting(n, None)` scans with the broadcast
join keyed on `i`? In the latter form the base code still fails all three
assertions, because `collectFirst` reaches the build scan first whatever the
join keys are.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoin.scala:
##########
@@ -85,18 +90,18 @@ object CoalesceBucketsInJoin extends Rule[SparkPlan] {
}
plan transform {
- case ExtractJoinWithBuckets(join, numLeftBuckets, numRightBuckets)
+ case ExtractJoinWithBuckets(join, leftScan, rightScan, numLeftBuckets,
numRightBuckets)
if math.max(numLeftBuckets, numRightBuckets) /
math.min(numLeftBuckets, numRightBuckets) <=
Review Comment:
Minor: this ratio check now uses the streamed scan's count, so some plans
that the base coalesced correctly now get a shuffle, and the "user-facing
change" section does not say so. With the default `maxBucketRatio` of 4, take
`SMJ(BHJ(BuildLeft, dim16, fact64), other8)` with every scan bucketed on the
join keys. The base took 16 from the build scan (ratio 2) and stamped fact64
with 8, which was valid because 64 % 8 == 0, so the join ran without a shuffle.
Now the ratio is 64 / 8 = 8, the rule declines, and EnsureRequirements shuffles
other8. That follows the documented meaning of
`spark.sql.bucketing.coalesceBucketsInJoin.maxBucketRatio`, so the new behavior
looks right to me. Likewise, a bucketed build scan is no longer stamped, so its
broadcast read uses its own bucket count instead of the coalesced one. I traced
this by reading the code and did not run it. Could the description list these
next to the newly enabled 16-to-8 case?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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)))
+ }
+ }
+
+ test("SPARK-60382: broadcast hash join bucket coalescing keeps the
sort-merge match") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.CBO_ENABLED.key -> "false",
+ SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true",
+ SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> ReorderJoin.ruleName) {
+ 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 executed = spark.sql(query).queryExecution.executedPlan
Review Comment:
Nit: the plan check builds its own `Dataset` here, and `checkAnswer` at L365
builds and plans another one, so the asserted plan is not the one that runs.
They are the same today with AQE off. Could this be `val df =
spark.sql(query)`, with `df.queryExecution.executedPlan` here and
`checkAnswer(df, Seq(Row(key, "fact", "other")))` at L365?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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)))
+ }
+ }
+
+ test("SPARK-60382: broadcast hash join bucket coalescing keeps the
sort-merge match") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.CBO_ENABLED.key -> "false",
+ SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true",
+ SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> ReorderJoin.ruleName) {
+ 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 executed = spark.sql(query).queryExecution.executedPlan
+ val scans = executed.collect { case f: FileSourceScanExec =>
+ (f.relation.bucketSpec.map(_.numBuckets),
f.optionalNumCoalescedBuckets)
+ }
+ assert(scans == Seq((Some(16), None), (Some(100), None), (Some(8),
None)), executed)
Review Comment:
Minor: these two checks would also pass on the base code if the inner join
stopped being a `BuildLeft` broadcast hash join. If it were planned as a
sort-merge join, or with dim16 as the streamed side, the base rule would not
coalesce anything either: all three scans would stay `(Some(n), None)` in the
same order, and the answer would be right. That is unlikely today, since
`BROADCAST` takes precedence over `MERGE` on the same join, and the plan-level
test above still guards the rule. Could this also assert the shape that the
test relies on, e.g. `assert(executed.collectFirst { case b:
BroadcastHashJoinExec => b.buildSide }.contains(BuildLeft), executed)`?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoin.scala:
##########
@@ -111,26 +116,34 @@ object CoalesceBucketsInJoin extends Rule[SparkPlan] {
* and numbers of buckets are not equal but divisible.
*/
object ExtractJoinWithBuckets {
- @tailrec
- private def hasScanOperation(plan: SparkPlan): Boolean = plan match {
- case f: FilterExec => hasScanOperation(f.child)
- case p: ProjectExec => hasScanOperation(p.child)
- case j: BroadcastHashJoinExec =>
- if (j.buildSide == BuildLeft) hasScanOperation(j.right) else
hasScanOperation(j.left)
- case j: BroadcastNestedLoopJoinExec =>
- if (j.buildSide == BuildLeft) hasScanOperation(j.right) else
hasScanOperation(j.left)
- case f: FileSourceScanExec => f.relation.bucketSpec.nonEmpty
- case _ => false
+ private def streamedSide(
+ buildSide: BuildSide, left: SparkPlan, right: SparkPlan): SparkPlan = {
+ if (buildSide == BuildLeft) right else left
}
- private def getBucketSpec(plan: SparkPlan): Option[BucketSpec] = {
- plan.collectFirst {
- case f: FileSourceScanExec if f.relation.bucketSpec.nonEmpty &&
- f.optionalNumCoalescedBuckets.isEmpty =>
- f.relation.bucketSpec.get
+ /**
+ * Bucketed scan whose partitioning this side exposes. Broadcast joins
Review Comment:
Minor: this doc says more than `findBucketedScan` checks. L138 returns any
scan with `relation.bucketSpec.nonEmpty`, including one whose `bucketedScan` is
false (bucketing disabled or a bucket column pruned) and one behind a
nested-loop join type that reports `UnknownPartitioning(0)`, so the returned
scan does not always expose its partitioning to this side. The real guard is
`satisfiesOutputPartitioning` at L177-178. Could this say something like "The
only bucketed scan whose partitioning can reach the parent join through this
side; the caller still checks the side's `outputPartitioning`."?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoin.scala:
##########
@@ -111,26 +116,34 @@ object CoalesceBucketsInJoin extends Rule[SparkPlan] {
* and numbers of buckets are not equal but divisible.
*/
object ExtractJoinWithBuckets {
- @tailrec
- private def hasScanOperation(plan: SparkPlan): Boolean = plan match {
- case f: FilterExec => hasScanOperation(f.child)
- case p: ProjectExec => hasScanOperation(p.child)
- case j: BroadcastHashJoinExec =>
- if (j.buildSide == BuildLeft) hasScanOperation(j.right) else
hasScanOperation(j.left)
- case j: BroadcastNestedLoopJoinExec =>
- if (j.buildSide == BuildLeft) hasScanOperation(j.right) else
hasScanOperation(j.left)
- case f: FileSourceScanExec => f.relation.bucketSpec.nonEmpty
- case _ => false
+ private def streamedSide(
+ buildSide: BuildSide, left: SparkPlan, right: SparkPlan): SparkPlan = {
+ if (buildSide == BuildLeft) right else left
}
- private def getBucketSpec(plan: SparkPlan): Option[BucketSpec] = {
- plan.collectFirst {
- case f: FileSourceScanExec if f.relation.bucketSpec.nonEmpty &&
- f.optionalNumCoalescedBuckets.isEmpty =>
- f.relation.bucketSpec.get
+ /**
+ * Bucketed scan whose partitioning this side exposes. Broadcast joins
+ * expose the streamed child, so a bucketed build scan is not the count
+ * the parent sort-merge or shuffled-hash join coalesces against.
+ */
+ @tailrec
+ private def findBucketedScan(plan: SparkPlan): Option[FileSourceScanExec] = {
+ plan match {
+ case f: FilterExec => findBucketedScan(f.child)
+ case p: ProjectExec => findBucketedScan(p.child)
+ case j: BroadcastHashJoinExec =>
+ findBucketedScan(streamedSide(j.buildSide, j.left, j.right))
+ case j: BroadcastNestedLoopJoinExec =>
+ findBucketedScan(streamedSide(j.buildSide, j.left, j.right))
+ case f: FileSourceScanExec if f.relation.bucketSpec.nonEmpty => Some(f)
+ case _ => None
}
}
+ private def getBucketSpec(scan: FileSourceScanExec): Option[BucketSpec] = {
Review Comment:
Nit: `findBucketedScan` already guarantees `relation.bucketSpec.nonEmpty`
(L138), so this helper only rejects a scan that is already coalesced. Could
that check move into the walk's leaf case, `case f: FileSourceScanExec if
f.relation.bucketSpec.nonEmpty && f.optionalNumCoalescedBuckets.isEmpty =>
Some(f)`, with L179-180 reading `leftScan.relation.bucketSpec` and
`rightScan.relation.bucketSpec`? A coalesced leaf then falls to `case _ =>
None`, so the extractor still returns `None`, and the `BucketSpec` import at
L22 goes away.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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)))
+ }
+ }
+
+ test("SPARK-60382: broadcast hash join bucket coalescing keeps the
sort-merge match") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.CBO_ENABLED.key -> "false",
+ SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true",
+ SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> ReorderJoin.ruleName) {
+ 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)
Review Comment:
Nit: this view re-writes Spark's bucket-id function in SQL and is evaluated
three times, by the two `INSERT ... SELECT` statements and by the read-back at
L352. `BucketingUtils.getBucketIdFromValue` already computes the writer's
bucket id, and BucketedReadSuite.scala:140 uses it that way. SQL `hash` and the
writer's hash agree for an INT key, so this is about clarity and cost, not
correctness today. Could the key be computed once, e.g. `val iAttr =
AttributeReference("i", IntegerType)()` and `val key = (0 until 4000).find(v =>
BucketingUtils.getBucketIdFromValue(iAttr, 100, v) % 8 !=
BucketingUtils.getBucketIdFromValue(iAttr, 8, v)).get`, and inserted with
`VALUES`? That also removes the view and its `withTempView`.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/bucketing/CoalesceBucketsInJoinSuite.scala:
##########
@@ -262,6 +263,111 @@ 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)))
+ }
+ }
+
+ test("SPARK-60382: broadcast hash join bucket coalescing keeps the
sort-merge match") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.CBO_ENABLED.key -> "false",
+ SQLConf.COALESCE_BUCKETS_IN_JOIN_ENABLED.key -> "true",
+ SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> ReorderJoin.ruleName) {
Review Comment:
Nit: three of these five confs have no effect on this query, and this line
also replaces the suite-wide
`spark.sql.optimizer.excludedRules=ConvertToLocalRelation`
(SparkSessionBinder.scala:90) for the test, because `withSQLConf` overwrites
the value instead of appending to it.
- `ReorderJoin` does not touch hinted joins: `ExtractFiltersAndInnerJoins`
matches only a `Join` whose `hint == JoinHint.NONE` (patterns.scala:270 and
:272), and both joins here carry hints after `EliminateResolvedHint`.
- `spark.sql.cbo.enabled` (L327) is false by default.
- `spark.sql.autoBroadcastJoinThreshold` (L326) is not consulted, because
`JoinSelection` takes both strategies from the hints.
Re-enabling `ConvertToLocalRelation` changes nothing here, but the extra
pins suggest that each one is needed for the repro. Could L326, L327 and L329
be dropped, together with the `ReorderJoin` import at L23?
--
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]