Skip to content

Commit cf49678

Browse files
committed
[SPARK-36082][SQL][FOLLOWUP] Preserve NAAJ broadcast fallback behavior
### What changes were proposed in this pull request? This is a follow-up to [#55678](#55678). It preserves the optimized single-column null-aware anti join (NAAJ) hash path without making the fallback worse than it was before that change. The patch recognizes the structural single-column NAAJ form independently of whether the hash optimization is enabled. `SparkStrategies.JoinSelection` then owns the complete physical decision for that form. A shared `NullAwareAntiJoinPlanning` decision selects: 1. the specialized null-aware `BroadcastHashJoinExec`, which only supports `BuildRight`, when the optimization is enabled, the right side is within `spark.sql.autoBroadcastJoinThreshold`, and the generic broadcast nested-loop fallback would also build the right side; or 2. `BroadcastNestedLoopJoinExec` with the exact build side selected by the generic fallback. The size boundary is checked before fallback build-side selection. Therefore, an over-threshold right side rejects the hash path without computing irrelevant statistics for an uncached left subtree. For non-inner/full joins, the nested-loop helper also checks the fixed preferred side before consulting the opposite side's statistics. Logical eligibility checks delegate to the same decision used by physical planning. This avoids reimplementing physical hint precedence and build-side rules in the optimizer while still letting the optimizer ask whether a plan will use the specialized hash operator. Aggregate pushdown keeps separate stability guards. Structural NAAJs require both the original and the pushed form to select the specialized hash outcome. Ordinary semi and anti joins preserve the SPARK-34081 broadcast-eligibility gate: when the original join cannot use broadcast hash join, the join remains above the Aggregate so the Aggregate can reduce its input first. ### Why are the changes needed? The null-aware hash operator always builds the right side, while the generic broadcast nested-loop join can build either side. If the fallback chooses `BuildLeft`, forcing the specialized hash path can require broadcasting a much larger right side and regress a query that previously had a valid plan. An over-threshold `BuildRight` input also used the nested-loop fallback before #55678. Planner estimates do not provide a reliable proof that every such input fits the runtime hash representation, so this follow-up keeps the hash optimization inside the existing broadcast-size boundary. This also preserves the nested-loop equality semantics for threshold-disabled floating-point NAAJs, including `-0.0` versus `0.0`. Making the final choice in the physical planner gives the structural NAAJ exactly one owner. It also ensures that disabling the hash optimization takes the explicit nested-loop fallback instead of depending on the ordinary join-selection path to rediscover the right behavior. The optimizer must also preserve the existing SPARK-34081 behavior for ordinary semi and anti joins. Unconditionally moving such a join below an Aggregate when broadcasting is disabled makes a shuffle join consume the ungrouped source rows and reintroduces the performance regression that SPARK-34081 avoided. The ordinary branch therefore retains its broadcast-eligibility guard without changing the structural NAAJ decision. ### Does this PR introduce _any_ user-facing change? Yes. Relative to #55678 on unreleased branches, a recognized single-column NAAJ uses the null-aware broadcast hash join only when the generic fallback would build the right side and that side is within the normal broadcast-size threshold. If the optimization is disabled, the fallback would build the left side, or the right side exceeds the threshold, Spark retains the broadcast nested-loop join. Ordinary semi and anti Aggregate pushdown also retains its released broadcast-eligibility behavior. Released Spark behavior is not changed. ### How was this patch tested? The NAAJ changes were tested with these focused checks: ``` build/sbt \ "catalyst/testOnly org.apache.spark.sql.catalyst.optimizer.JoinSelectionHelperSuite" \ "catalyst/testOnly org.apache.spark.sql.catalyst.optimizer.LeftSemiAntiJoinPushDownSuite" \ "sql/testOnly org.apache.spark.sql.JoinSuite -- -z SPARK-36082" ``` After restoring the ordinary-join gate, the final head also passed: ``` build/sbt 'catalyst/testOnly *LeftSemiAntiJoinPushDownSuite -- -z "SPARK-34081"' ``` The tests cover the in-threshold `BuildRight` hash path, disabled optimization, the `BuildLeft` fallback, the over-threshold `BuildRight` fallback, uncached-left statistics short-circuiting, floating-point signed-zero correctness, structural-NAAJ Aggregate stability, and broadcast-eligible versus disabled ordinary semi/anti Aggregate pushdown. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: OpenAI Codex (GPT-5) Closes #58404 from cloud-fan/fix-naaj-broadcast-fallback. Authored-by: Wenchen Fan <wenchen@databricks.com> Signed-off-by: Wenchen Fan <wenchen@databricks.com> (cherry picked from commit f8b673c) Signed-off-by: Wenchen Fan <wenchen@databricks.com>
1 parent 02c18c1 commit cf49678

7 files changed

Lines changed: 375 additions & 73 deletions

File tree

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/PushDownLeftSemiAntiJoin.scala

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
package org.apache.spark.sql.catalyst.optimizer
1919

2020
import org.apache.spark.sql.catalyst.expressions._
21+
import org.apache.spark.sql.catalyst.planning.ExtractSingleColumnNullAwareAntiJoin
2122
import org.apache.spark.sql.catalyst.plans._
2223
import org.apache.spark.sql.catalyst.plans.logical._
2324
import org.apache.spark.sql.catalyst.rules.Rule
@@ -60,12 +61,11 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
6061
}
6162
}
6263

63-
// LeftSemi/LeftAnti over Aggregate, only push down if join can be planned as broadcast join.
64+
// LeftSemi/LeftAnti over Aggregate
6465
case join @ Join(agg: Aggregate, rightOp, LeftSemiOrAnti(_), joinCond, _)
6566
if agg.aggregateExpressions.forall(_.deterministic) && agg.groupingExpressions.nonEmpty &&
6667
!agg.aggregateExpressions.exists(ScalarSubquery.hasCorrelatedScalarSubquery) &&
67-
canPushThroughCondition(agg.children, joinCond, rightOp) &&
68-
canPlanAsBroadcastHashJoin(join, conf) =>
68+
canPushThroughCondition(agg.children, joinCond, rightOp) =>
6969
val aliasMap = getAliasMap(agg)
7070
val canPushDownPredicate = (predicate: Expression) => {
7171
val replaced = replaceAlias(predicate, aliasMap)
@@ -75,7 +75,20 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
7575
val makeJoinCondition = (predicates: Seq[Expression]) => {
7676
replaceAlias(predicates.reduce(And), aliasMap)
7777
}
78-
pushDownJoin(join, canPushDownPredicate, makeJoinCondition)
78+
val canPushDownJoin = if (ExtractSingleColumnNullAwareAntiJoin.extract(join).isDefined) {
79+
val originalIsBroadcastHash =
80+
NullAwareAntiJoinPlanning.decide(join, conf) == NullAwareAntiJoinPlanning.BroadcastHash
81+
(pushedJoin: Join) => originalIsBroadcastHash &&
82+
NullAwareAntiJoinPlanning.decide(pushedJoin, conf) ==
83+
NullAwareAntiJoinPlanning.BroadcastHash
84+
} else {
85+
(_: Join) => canPlanAsBroadcastHashJoin(join, conf)
86+
}
87+
pushDownJoin(
88+
join,
89+
canPushDownPredicate,
90+
makeJoinCondition,
91+
canPushDownJoin)
7992

8093
// LeftSemi/LeftAnti over Window
8194
case join @ Join(w: Window, rightOp, LeftSemiOrAnti(_), _, _)
@@ -133,11 +146,17 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
133146
private def pushDownJoin(
134147
join: Join,
135148
canPushDownPredicate: Expression => Boolean,
136-
makeJoinCondition: Seq[Expression] => Expression): LogicalPlan = {
149+
makeJoinCondition: Seq[Expression] => Expression,
150+
canPushDownJoin: Join => Boolean = _ => true): LogicalPlan = {
137151
assert(join.left.children.length == 1)
138152

139153
if (join.condition.isEmpty) {
140-
join.left.withNewChildren(Seq(join.copy(left = join.left.children.head)))
154+
val pushedJoin = join.copy(left = join.left.children.head)
155+
if (canPushDownJoin(pushedJoin)) {
156+
join.left.withNewChildren(Seq(pushedJoin))
157+
} else {
158+
join
159+
}
141160
} else {
142161
val (pushDown, stayUp) = splitConjunctivePredicates(join.condition.get)
143162
.partition(canPushDownPredicate)
@@ -151,19 +170,25 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
151170
if (pushDown.isEmpty || referRightSideCols) {
152171
join
153172
} else {
154-
val newPlan = join.left.withNewChildren(Seq(join.copy(
155-
left = join.left.children.head, condition = Some(makeJoinCondition(pushDown)))))
156-
// If there is no more filter to stay up, return the new plan that has join pushed down.
157-
if (stayUp.isEmpty) {
158-
newPlan
173+
val pushedJoin = join.copy(
174+
left = join.left.children.head, condition = Some(makeJoinCondition(pushDown)))
175+
if (!canPushDownJoin(pushedJoin)) {
176+
join
159177
} else {
160-
join.joinType match {
161-
// In case of Left semi join, the part of the join condition which does not refer to
162-
// to attributes of the grandchild are kept as a Filter above.
163-
case LeftSemi => Filter(stayUp.reduce(And), newPlan)
164-
// In case of left-anti join, the join is pushed down only when the entire join
165-
// condition is eligible to be pushed down to preserve the semantics of left-anti join.
166-
case _ => join
178+
val newPlan = join.left.withNewChildren(Seq(pushedJoin))
179+
// If no predicates remain above the join, return the plan with the join pushed down.
180+
if (stayUp.isEmpty) {
181+
newPlan
182+
} else {
183+
join.joinType match {
184+
// For a left semi join, the non-pushable part of the condition is kept as a Filter
185+
// above the join.
186+
case LeftSemi => Filter(stayUp.reduce(And), newPlan)
187+
// In the case of a left anti join, the join is pushed down only when the entire join
188+
// condition is eligible to be pushed down to preserve the semantics of the left anti
189+
// join.
190+
case _ => join
191+
}
167192
}
168193
}
169194
}
@@ -273,5 +298,3 @@ object PushLeftSemiLeftAntiThroughJoin extends Rule[LogicalPlan] with PredicateH
273298
}
274299
}
275300
}
276-
277-

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,66 @@ trait JoinSelectionHelper extends Logging {
353353
}
354354
}
355355

356+
def getBroadcastNestedLoopJoinDesiredBuildSide(join: Join): BuildSide = {
357+
if (join.joinType.isInstanceOf[InnerLike] || join.joinType == FullOuter) {
358+
getSmallerSide(join.left, join.right)
359+
} else {
360+
// For perf reasons, BroadcastNestedLoopJoinExec prefers to broadcast the left side for a
361+
// right join and the right side for a left join. If one side is much smaller, revisiting
362+
// that preference may be worthwhile.
363+
if (canBuildBroadcastLeft(join.joinType)) BuildLeft else BuildRight
364+
}
365+
}
366+
367+
def getBroadcastNestedLoopJoinBuildSide(
368+
join: Join,
369+
hintOnly: Boolean,
370+
conf: SQLConf): Option[BuildSide] = {
371+
lazy val buildLeft = if (hintOnly) {
372+
hintToBroadcastLeft(join.hint)
373+
} else {
374+
canBroadcastBySize(join.left, conf) &&
375+
!hintToNotBroadcastAndReplicateLeft(join.hint)
376+
}
377+
lazy val buildRight = if (hintOnly) {
378+
hintToBroadcastRight(join.hint)
379+
} else {
380+
canBroadcastBySize(join.right, conf) &&
381+
!hintToNotBroadcastAndReplicateRight(join.hint)
382+
}
383+
384+
if (join.joinType.isInstanceOf[InnerLike] || join.joinType == FullOuter) {
385+
if (buildLeft && buildRight) {
386+
Some(getBroadcastNestedLoopJoinDesiredBuildSide(join))
387+
} else if (buildLeft) {
388+
Some(BuildLeft)
389+
} else if (buildRight) {
390+
Some(BuildRight)
391+
} else {
392+
None
393+
}
394+
} else {
395+
getBroadcastNestedLoopJoinDesiredBuildSide(join) match {
396+
case BuildLeft =>
397+
if (buildLeft) Some(BuildLeft) else if (buildRight) Some(BuildRight) else None
398+
case BuildRight =>
399+
if (buildRight) Some(BuildRight) else if (buildLeft) Some(BuildLeft) else None
400+
}
401+
}
402+
}
403+
404+
def getBroadcastNestedLoopJoinBuildSide(join: Join, conf: SQLConf): BuildSide = {
405+
val hintedBuildSide = if (join.hint.isEmpty) {
406+
None
407+
} else {
408+
getBroadcastNestedLoopJoinBuildSide(join, hintOnly = true, conf)
409+
}
410+
hintedBuildSide
411+
.orElse(getBroadcastNestedLoopJoinBuildSide(join, hintOnly = false, conf))
412+
.orElse(getBroadcastNestedLoopJoinBuildSide(join.hint, join.joinType))
413+
.getOrElse(getBroadcastNestedLoopJoinDesiredBuildSide(join))
414+
}
415+
356416
def getSmallerSide(left: LogicalPlan, right: LogicalPlan): BuildSide = {
357417
if (right.stats.sizeInBytes <= left.stats.sizeInBytes) BuildRight else BuildLeft
358418
}
@@ -438,9 +498,13 @@ trait JoinSelectionHelper extends Logging {
438498
getBroadcastBuildSide(join, hintOnly = true, conf).orElse {
439499
if (noShufflePlannedBefore) getBroadcastBuildSide(join, hintOnly = false, conf) else None
440500
}
441-
// `JoinSelection` always builds from the right for this shape.
442-
case j @ ExtractSingleColumnNullAwareAntiJoin(_, _) =>
443-
if (canBroadcastBySize(j.right, conf)) Some(BuildRight) else None
501+
case j if ExtractSingleColumnNullAwareAntiJoin.extract(j).isDefined =>
502+
if (NullAwareAntiJoinPlanning.decide(j, conf) ==
503+
NullAwareAntiJoinPlanning.BroadcastHash) {
504+
Some(BuildRight)
505+
} else {
506+
None
507+
}
444508
case _ => None
445509
}
446510

@@ -564,3 +628,19 @@ trait JoinSelectionHelper extends Logging {
564628
conf.getConfString("spark.sql.join.forceApplyShuffledHashJoin", "false") == "true"
565629
}
566630
}
631+
632+
private[sql] object NullAwareAntiJoinPlanning extends JoinSelectionHelper {
633+
sealed trait Decision
634+
case object BroadcastHash extends Decision
635+
case object BroadcastNestedLoop extends Decision
636+
637+
def decide(join: Join, conf: SQLConf): Decision = {
638+
if (conf.optimizeNullAwareAntiJoin &&
639+
canBroadcastBySize(join.right, conf) &&
640+
getBroadcastNestedLoopJoinBuildSide(join, conf) == BuildRight) {
641+
BroadcastHash
642+
} else {
643+
BroadcastNestedLoop
644+
}
645+
}
646+
}

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/planning/patterns.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,11 @@ object ExtractSingleColumnNullAwareAntiJoin extends JoinSelectionHelper with Pre
404404
* But if it's a single column case O(M*N) calculation could be optimized into O(M)
405405
* using hash lookup instead of loop lookup.
406406
*/
407-
def unapply(join: Join): Option[ReturnType] = join match {
407+
private[sql] def extract(join: Join): Option[ReturnType] = join match {
408408
case Join(left, right, LeftAnti,
409409
Some(Or(e @ EqualTo(leftAttr: Expression, rightAttr: Expression),
410410
IsNull(e2 @ EqualTo(_, _)))), _)
411-
if SQLConf.get.optimizeNullAwareAntiJoin &&
412-
e.semanticEquals(e2) =>
411+
if e.semanticEquals(e2) =>
413412
if (canEvaluate(leftAttr, left) && canEvaluate(rightAttr, right)) {
414413
Some(Seq(leftAttr), Seq(rightAttr))
415414
} else if (canEvaluate(leftAttr, right) && canEvaluate(rightAttr, left)) {
@@ -419,6 +418,10 @@ object ExtractSingleColumnNullAwareAntiJoin extends JoinSelectionHelper with Pre
419418
}
420419
case _ => None
421420
}
421+
422+
def unapply(join: Join): Option[ReturnType] = {
423+
if (SQLConf.get.optimizeNullAwareAntiJoin) extract(join) else None
424+
}
422425
}
423426

424427
/**

sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/JoinSelectionHelperSuite.scala

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,26 @@
1717

1818
package org.apache.spark.sql.catalyst.optimizer
1919

20+
import java.util.concurrent.atomic.AtomicBoolean
21+
2022
import org.apache.spark.sql.catalyst.dsl.expressions._
21-
import org.apache.spark.sql.catalyst.expressions.{AttributeMap, EqualTo, IsNull, Or}
22-
import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti, PlanTest}
23-
import org.apache.spark.sql.catalyst.plans.logical.{BROADCAST, HintInfo, Join, JoinHint, NO_BROADCAST_HASH, SHUFFLE_HASH}
23+
import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap, EqualTo, IsNull, Or}
24+
import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti, PlanTest, RightOuter}
25+
import org.apache.spark.sql.catalyst.plans.logical.{BROADCAST, HintInfo, Join, JoinHint, LeafNode, NO_BROADCAST_HASH, SHUFFLE_HASH, Statistics}
2426
import org.apache.spark.sql.catalyst.statsEstimation.StatsTestPlan
2527
import org.apache.spark.sql.internal.SQLConf
2628

2729
class JoinSelectionHelperSuite extends PlanTest with JoinSelectionHelper {
2830

31+
private case class TrackingStatsTestPlan(
32+
override val output: Seq[Attribute],
33+
statsAccessed: AtomicBoolean) extends LeafNode {
34+
override def computeStats(): Statistics = {
35+
statsAccessed.set(true)
36+
Statistics(sizeInBytes = 20000000)
37+
}
38+
}
39+
2940
private val left = StatsTestPlan(
3041
outputList = Seq($"a".int, $"b".int, $"c".int),
3142
rowCount = 20000000,
@@ -149,25 +160,71 @@ class JoinSelectionHelperSuite extends PlanTest with JoinSelectionHelper {
149160
assert(getSmallerSide(left, right) === BuildRight)
150161
}
151162

163+
test("getBroadcastNestedLoopJoinBuildSide checks the fixed desired side first") {
164+
val leftStatsAccessed = new AtomicBoolean(false)
165+
val uncachedLeft = TrackingStatsTestPlan(Seq($"uncachedLeft".int), leftStatsAccessed)
166+
val leftAntiJoin = Join(uncachedLeft, right, LeftAnti, None, JoinHint.NONE)
167+
val rightStatsAccessed = new AtomicBoolean(false)
168+
val uncachedRight = TrackingStatsTestPlan(Seq($"uncachedRight".int), rightStatsAccessed)
169+
val rightOuterJoin = Join(right, uncachedRight, RightOuter, None, JoinHint.NONE)
170+
171+
withSQLConf(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB") {
172+
assert(getBroadcastNestedLoopJoinBuildSide(leftAntiJoin, SQLConf.get) === BuildRight)
173+
assert(!leftStatsAccessed.get())
174+
assert(getBroadcastNestedLoopJoinBuildSide(rightOuterJoin, SQLConf.get) === BuildLeft)
175+
assert(!rightStatsAccessed.get())
176+
}
177+
}
178+
152179
test("canBroadcastBySize should return true if the plan size is less than 10MB") {
153180
withSQLConf(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB") {
154181
assert(canBroadcastBySize(left, SQLConf.get) === false)
155182
assert(canBroadcastBySize(right, SQLConf.get) === true)
156183
}
157184
}
158185

159-
test("canPlanAsBroadcastHashJoin should respect size for single-column null-aware anti join") {
186+
test("canPlanAsBroadcastHashJoin should respect NAAJ size and nested-loop build side") {
160187
val leftKey = left.output.head
161188
val rightKey = right.output.head
162189
val condition = Or(EqualTo(leftKey, rightKey), IsNull(EqualTo(leftKey, rightKey)))
163190
val nullAwareAntiJoin = Join(left, right, LeftAnti, Some(condition), JoinHint.NONE)
191+
val smallLeft = left.copy(rowCount = 1000, size = Some(1000))
164192
val largeRight = right.copy(rowCount = 20000000, size = Some(20000000))
165193

166194
withSQLConf(
167195
SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
168196
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB") {
169197
assert(canPlanAsBroadcastHashJoin(nullAwareAntiJoin, SQLConf.get))
170-
assert(!canPlanAsBroadcastHashJoin(nullAwareAntiJoin.copy(right = largeRight), SQLConf.get))
198+
assert(!canPlanAsBroadcastHashJoin(
199+
nullAwareAntiJoin.copy(right = largeRight.copy(rowCount = 1)), SQLConf.get))
200+
assert(!canPlanAsBroadcastHashJoin(
201+
nullAwareAntiJoin.copy(left = smallLeft, right = largeRight), SQLConf.get))
202+
assert(!canPlanAsBroadcastHashJoin(
203+
nullAwareAntiJoin.copy(hint = JoinHint(hintBroadcast, None)), SQLConf.get))
204+
}
205+
206+
withSQLConf(
207+
SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "false",
208+
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB") {
209+
assert(!canPlanAsBroadcastHashJoin(nullAwareAntiJoin, SQLConf.get))
210+
}
211+
}
212+
213+
test("canPlanAsBroadcastHashJoin checks the NAAJ right-size short circuit") {
214+
val leftStatsAccessed = new AtomicBoolean(false)
215+
val uncachedLeft = TrackingStatsTestPlan(Seq($"uncachedLeft".int), leftStatsAccessed)
216+
val largeRight = right.copy(rowCount = 20000000, size = Some(20000000))
217+
val leftKey = uncachedLeft.output.head
218+
val rightKey = largeRight.output.head
219+
val condition = Or(EqualTo(leftKey, rightKey), IsNull(EqualTo(leftKey, rightKey)))
220+
val nullAwareAntiJoin = Join(
221+
uncachedLeft, largeRight, LeftAnti, Some(condition), JoinHint.NONE)
222+
223+
withSQLConf(
224+
SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
225+
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB") {
226+
assert(!canPlanAsBroadcastHashJoin(nullAwareAntiJoin, SQLConf.get))
227+
assert(!leftStatsAccessed.get())
171228
}
172229
}
173230

0 commit comments

Comments
 (0)