LuciferYang commented on code in PR #58870:
URL: https://github.com/apache/spark/pull/58870#discussion_r4059088142


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -438,12 +438,33 @@ trait JoinSelectionHelper extends Logging {
       getBroadcastBuildSide(join, hintOnly = true, conf).orElse {
         if (noShufflePlannedBefore) getBroadcastBuildSide(join, hintOnly = 
false, conf) else None
       }
-    // `JoinSelection` always builds from the right for this shape. A negative 
threshold preserves
-    // the original unbounded NAAJ behavior, while zero disables the broadcast 
hash optimization.
+    // `JoinSelection` always builds from the right for this shape. A 
dedicated threshold that
+    // admits the right side takes precedence over join hints. The automatic 
threshold is only
+    // used as a floor when regular planning would also broadcast the right 
side; a left-only
+    // broadcast hint makes the fallback broadcast the left side instead.
     case j @ ExtractSingleColumnNullAwareAntiJoin(_, _) =>
-      val threshold = conf.nullAwareAntiJoinBroadcastThreshold
-      val rightSize = j.right.stats.sizeInBytes
-      if (threshold < 0 || (threshold > 0 && rightSize >= 0 && rightSize <= 
threshold)) {
+      val dedicatedThreshold = conf.nullAwareAntiJoinBroadcastThreshold
+      val canBroadcast = if (dedicatedThreshold < 0) {
+        true
+      } else {
+        val fallbackBuildsRight =

Review Comment:
   **LOW**
   
   `!hintToBroadcastLeft(j.hint) || hintToBroadcastRight(j.hint)` really tests 
"there is no broadcast hint on the left side only", which is weaker than the 
name: with no hint at all, a broadcastable left side and a right side that is 
not, it is true while the fallback builds the left side 
(`SparkStrategies.scala:376-403`). Nothing shows today because it is chained 
with `canBroadcastBySize(j.right, conf)` by `&&`, and that case makes the whole 
term false anyway.
   
   Renaming it (`noLeftOnlyBroadcastHint`, say), or noting beside it that it 
only holds inside this `&&`, would keep a later change from reusing it 
elsewhere.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/JoinSelectionHelperSuite.scala:
##########
@@ -195,48 +200,161 @@ class JoinSelectionHelperSuite extends PlanTest with 
JoinSelectionHelper {
     }
   }
 
-  test("getBroadcastHashJoinBuildSide uses the null-aware anti join broadcast 
threshold") {
-    val leftKey = left.output.head
-    val rightKey = right.output.head
-    val condition = Or(EqualTo(leftKey, rightKey), IsNull(EqualTo(leftKey, 
rightKey)))
-    val nullAwareAntiJoin = Join(left, right, LeftAnti, Some(condition), 
JoinHint.NONE)
-    val largeRight = right.copy(rowCount = 20000000, size = Some(20000000))
-    val negativeSizeRight = right.copy(size = Some(-1))
+  test("NAAJ broadcast threshold is floored by the automatic broadcast 
threshold") {
+    val autoThresholdRight = right.copy(
+      rowCount = 10 * 1024 * 1024,
+      size = Some(10 * 1024 * 1024))
+    val betweenThresholdsRight = right.copy(
+      rowCount = 8 * 1024 * 1024,
+      size = Some(8 * 1024 * 1024))
+    val largeRight = right.copy(
+      rowCount = 20 * 1024 * 1024,
+      size = Some(20 * 1024 * 1024))
+    val emptyRight = right.copy(rowCount = 0, size = Some(0))
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
+      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin(), SQLConf.get) 
=== Some(BuildRight))
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(autoThresholdRight), SQLConf.get) === 
Some(BuildRight))
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(autoThresholdRight).copy(
+          hint = JoinHint(hintBroadcast, None)), SQLConf.get).isEmpty)

Review Comment:
   **MEDIUM**
   
   The three new hint assertions (:222-224, :235-237, :338-340) all carry 
BROADCAST on the left only, and no NAAJ case anywhere hints the right side or 
both sides, so nothing exercises `|| hintToBroadcastRight(j.hint)`: dropping 
it, or turning the `||` into `&& !`, leaves every assertion in the three suites 
green.
   
   Two assertions close it: a both-sides-hinted case in this block (right side 
`autoThresholdRight`, expecting `Some(BuildRight)`), and a left-hinted case in 
`LeftSemiAntiJoinPushDownSuite.scala:174-178` expecting no pushdown. That suite 
carries no hint at all today.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -7452,12 +7452,24 @@ object SQLConf {
         "single-column null-aware anti join for which Spark uses the broadcast 
hash join " +
         "optimization. This configuration takes effect only when " +
         "spark.sql.optimizeNullAwareAntiJoin is enabled. A negative value 
allows the " +
-        "optimization regardless of the estimated size, while zero disables 
it. If the " +
-        "estimated size exceeds a positive value, Spark falls back to regular 
join planning. " +
+        "optimization regardless of the estimated size. For a nonnegative 
value, the " +
+        "optimization is also allowed when regular join planning would 
broadcast the right " +

Review Comment:
   **LOW**
   
   Two sentences in this paragraph do not match the code. :7456-7457 promises 
the optimization whenever regular planning would broadcast the right side, 
while the code requires the right side to pass the automatic threshold. The 
fallback does not consult size: with a BROADCAST hint on the right, or with no 
hint and both sides over the automatic threshold, it builds the right side, 
while the floor rejects once the right side is over the automatic threshold and 
the dedicated threshold does not admit it on its own.
   
   :7465-7466 runs the other way: it makes "the right side is ineligible for 
automatic broadcasting" the condition that has to accompany a dedicated 
threshold of zero, but with zero and a left-only BROADCAST hint the 
optimization is off while the right side is eligible.
   
   Since the paragraph is being rewritten anyway, all three can go in one pass: 
narrow the first to the automatic threshold, and add "or carries a broadcast 
hint only on the left" to the two that follow.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -438,12 +438,33 @@ trait JoinSelectionHelper extends Logging {
       getBroadcastBuildSide(join, hintOnly = true, conf).orElse {
         if (noShufflePlannedBefore) getBroadcastBuildSide(join, hintOnly = 
false, conf) else None
       }
-    // `JoinSelection` always builds from the right for this shape. A negative 
threshold preserves
-    // the original unbounded NAAJ behavior, while zero disables the broadcast 
hash optimization.
+    // `JoinSelection` always builds from the right for this shape. A 
dedicated threshold that
+    // admits the right side takes precedence over join hints. The automatic 
threshold is only
+    // used as a floor when regular planning would also broadcast the right 
side; a left-only
+    // broadcast hint makes the fallback broadcast the left side instead.
     case j @ ExtractSingleColumnNullAwareAntiJoin(_, _) =>
-      val threshold = conf.nullAwareAntiJoinBroadcastThreshold
-      val rightSize = j.right.stats.sizeInBytes
-      if (threshold < 0 || (threshold > 0 && rightSize >= 0 && rightSize <= 
threshold)) {
+      val dedicatedThreshold = conf.nullAwareAntiJoinBroadcastThreshold
+      val canBroadcast = if (dedicatedThreshold < 0) {
+        true
+      } else {
+        val fallbackBuildsRight =
+          !hintToBroadcastLeft(j.hint) || hintToBroadcastRight(j.hint)
+        val automaticBroadcastDisabled = conf.autoBroadcastJoinThreshold < 0 &&
+          conf.getConf(SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD).forall(_ 
< 0)
+        if (dedicatedThreshold == 0 && automaticBroadcastDisabled) {
+          // Avoid potentially expensive statistics computation when the 
configurations alone
+          // determine the result. Both automatic thresholds must be disabled 
because selecting
+          // between them requires reading `stats.isRuntime`.
+          false
+        } else {
+          (fallbackBuildsRight && canBroadcastBySize(j.right, conf)) ||

Review Comment:
   **LOW**
   
   The comment at :441-444 explains the hint term purely in join-selection 
terms, but `canPlanAsBroadcastHashJoin` has a second caller, 
`PushDownLeftSemiAntiJoin.scala:68`, which takes the same answer to decide 
whether a LeftSemi/LeftAnti join can move below an `Aggregate`. That answer is 
correct (with a left-only hint the plan really does become a BNLJ, and 
declining the pushdown is right), but nothing in the comment points at the 
second caller.
   
   Half a sentence — that this term also decides the aggregate pushdown — would 
cover it.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/JoinSelectionHelperSuite.scala:
##########
@@ -195,48 +200,161 @@ class JoinSelectionHelperSuite extends PlanTest with 
JoinSelectionHelper {
     }
   }
 
-  test("getBroadcastHashJoinBuildSide uses the null-aware anti join broadcast 
threshold") {
-    val leftKey = left.output.head
-    val rightKey = right.output.head
-    val condition = Or(EqualTo(leftKey, rightKey), IsNull(EqualTo(leftKey, 
rightKey)))
-    val nullAwareAntiJoin = Join(left, right, LeftAnti, Some(condition), 
JoinHint.NONE)
-    val largeRight = right.copy(rowCount = 20000000, size = Some(20000000))
-    val negativeSizeRight = right.copy(size = Some(-1))
+  test("NAAJ broadcast threshold is floored by the automatic broadcast 
threshold") {
+    val autoThresholdRight = right.copy(
+      rowCount = 10 * 1024 * 1024,
+      size = Some(10 * 1024 * 1024))
+    val betweenThresholdsRight = right.copy(
+      rowCount = 8 * 1024 * 1024,
+      size = Some(8 * 1024 * 1024))
+    val largeRight = right.copy(
+      rowCount = 20 * 1024 * 1024,
+      size = Some(20 * 1024 * 1024))
+    val emptyRight = right.copy(rowCount = 0, size = Some(0))
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
+      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin(), SQLConf.get) 
=== Some(BuildRight))
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(autoThresholdRight), SQLConf.get) === 
Some(BuildRight))
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(autoThresholdRight).copy(
+          hint = JoinHint(hintBroadcast, None)), SQLConf.get).isEmpty)
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(largeRight), SQLConf.get).isEmpty)
+    }
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "20MB") {
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(largeRight), SQLConf.get) === Some(BuildRight))
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(largeRight).copy(
+          hint = JoinHint(hintBroadcast, None)), SQLConf.get) === 
Some(BuildRight))
+    }
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "5MB") {
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(betweenThresholdsRight), SQLConf.get) === 
Some(BuildRight))
+    }
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
+      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin(), 
SQLConf.get).isEmpty)
+    }
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "0",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoin(emptyRight), SQLConf.get) === Some(BuildRight))
+      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin(), 
SQLConf.get).isEmpty)
+    }
+  }
+
+  test("NAAJ broadcast threshold is unlimited by default") {
     val overLongMaxRight = right.copy(
       rowCount = BigInt(Long.MaxValue) + 1,
       size = Some(BigInt(Long.MaxValue) + 1))
 
     withSQLConf(
       SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
-      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB") {
-      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin, SQLConf.get) === 
Some(BuildRight))
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
       assert(getBroadcastHashJoinBuildSide(
-        nullAwareAntiJoin.copy(right = largeRight), SQLConf.get) === 
Some(BuildRight))
+        nullAwareAntiJoin(overLongMaxRight), SQLConf.get) === Some(BuildRight))
+    }
+  }
+
+  test("NAAJ broadcast threshold uses the adaptive threshold for runtime 
statistics") {
+    case class RuntimeStatsPlan(size: BigInt) extends LeafNode {
+      override def output: Seq[Attribute] = right.output
+      override def computeStats(): Statistics = Statistics(sizeInBytes = size, 
isRuntime = true)
+    }
+    val runtimeRight = RuntimeStatsPlan(5 * 1024 * 1024)
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "1MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
+      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin(runtimeRight), 
SQLConf.get).isEmpty)
+    }
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "1MB",
+      SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
       assert(getBroadcastHashJoinBuildSide(
-        nullAwareAntiJoin.copy(right = overLongMaxRight), SQLConf.get) === 
Some(BuildRight))
+        nullAwareAntiJoin(runtimeRight), SQLConf.get) === Some(BuildRight))
     }
 
-    withSQLConf(SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "-2") {
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
       assert(getBroadcastHashJoinBuildSide(
-        nullAwareAntiJoin.copy(right = overLongMaxRight), SQLConf.get) === 
Some(BuildRight))
+        nullAwareAntiJoin(runtimeRight), SQLConf.get) === Some(BuildRight))
     }
+  }
 
-    withSQLConf(SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
-      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin, 
SQLConf.get).isEmpty)
+  test("NAAJ broadcast threshold does not read stats when eligibility is 
already known") {
+    case class ThrowingStatsPlan() extends LeafNode {
+      override def output: Seq[Attribute] = right.output
+      override def computeStats(): Statistics =
+        throw new IllegalStateException("statistics should not be read")
     }
+    val nullAwareAntiJoinWithoutStats = nullAwareAntiJoin(ThrowingStatsPlan())
 
     withSQLConf(
-      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "false",
-      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "-1") {
-      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin, 
SQLConf.get).isEmpty)
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "-2") {
+      assert(getBroadcastHashJoinBuildSide(
+        nullAwareAntiJoinWithoutStats, SQLConf.get) === Some(BuildRight))
     }
 
-    withSQLConf(SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> 
"10MB") {
-      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin, SQLConf.get) === 
Some(BuildRight))
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
+      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoinWithoutStats, 
SQLConf.get).isEmpty)
+    }
+
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "0") {
       assert(getBroadcastHashJoinBuildSide(
-        nullAwareAntiJoin.copy(right = largeRight), SQLConf.get).isEmpty)
+        nullAwareAntiJoinWithoutStats.copy(
+          hint = JoinHint(hintBroadcast, None)), SQLConf.get).isEmpty)
+    }
+  }
+
+  test("NAAJ broadcast threshold rejects unknown sizes and respects the 
optimization flag") {
+    withSQLConf(
+      SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD.key -> "10MB") {
+      assert(getBroadcastHashJoinBuildSide(nullAwareAntiJoin(), SQLConf.get) 
=== Some(BuildRight))
       assert(getBroadcastHashJoinBuildSide(
-        nullAwareAntiJoin.copy(right = negativeSizeRight), 
SQLConf.get).isEmpty)
+        nullAwareAntiJoin(right.copy(size = Some(-1))), SQLConf.get).isEmpty)

Review Comment:
   **LOW**
   
   The block on master that asserted `isEmpty` for a 20000000-byte right side 
under a 10MB dedicated threshold is gone. All three places in this suite where 
the dedicated threshold is positive now assert admission, and the only 
`isEmpty` left, at :351, is the `size = Some(-1)` case, which covers `rightSize 
>= 0` alone: cutting `rightSize >= 0 && rightSize <= dedicatedThreshold` down 
to `rightSize >= 0` upsets neither this suite nor 
`LeftSemiAntiJoinPushDownSuite`.
   
   The upper bound is still guarded, but only by the end-to-end case at 
`JoinSuite.scala:1285`, so a catalyst-only run cannot see it break. The old 
assertion still holds under the new code (20000000 bytes exceeds both the 10MB 
dedicated threshold and the default 10MB automatic one), so putting it back as 
it was is the cheapest fix.



-- 
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]

Reply via email to