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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -7452,12 +7452,22 @@ 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 considers the 
right side " +
+        "broadcastable. " +
+        "For join selection, regular planning uses " +
+        "spark.sql.adaptive.autoBroadcastJoinThreshold for runtime statistics 
when it is set, " +
+        "and spark.sql.autoBroadcastJoinThreshold otherwise. The same 
eligibility decision " +
+        "controls whether a null-aware anti join can be pushed below an 
aggregate; this " +

Review Comment:
   **MEDIUM**
   
   `canPlanAsBroadcastHashJoin` has a second consumer, 
`PushDownLeftSemiAntiJoin.scala:68`, which uses the answer to decide whether a 
LeftSemi/LeftAnti join can be pushed below an `Aggregate`. The floor widens 
that decision too, but "the fallback would broadcast the right side anyway" 
says nothing there: the pushdown leaves the right side alone and replaces the 
left side with the aggregate's input, so the cost turns on how many rows the 
aggregate removes, which the automatic broadcast threshold does not describe.
   
   With a dedicated threshold of 0, the default 10MB automatic threshold, a 5MB 
right side and `SELECT DISTINCT k FROM t` on the left (10^9 rows, 10^6 distinct 
keys), the anti join used to stay above the aggregate and probe 10^6 rows; it 
is now pushed below and probes 10^9. Both plans are correct, the cost estimate 
is what changed. Default-configured Spark (dedicated threshold -1) answers true 
either way and is unaffected.
   
   If sharing one predicate is intended, this sentence should say that the 
floor's rationale only covers join selection; if not, the pushdown gate can 
keep the plain dedicated-threshold test.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -438,12 +438,26 @@ 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. Do not 
reject the hash
+    // optimization when regular join planning would broadcast the right side, 
as the fallback
+    // would still broadcast it with a slower nested-loop join.
     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 automaticBroadcastDisabled = conf.autoBroadcastJoinThreshold < 0 &&
+          conf.getConf(SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD).forall(_ 
< 0)
+        if (dedicatedThreshold == 0 && automaticBroadcastDisabled) {

Review Comment:
   **LOW**
   
   When `automaticBroadcastDisabled` holds, the threshold `canBroadcastBySize` 
picks is negative and the check is always false, and `dedicatedThreshold == 0` 
makes the right half of the `||` false. This branch therefore changes no return 
value in any configuration; it only decides whether `j.right.stats` is read.
   
   Skipping that read is worth something (a DSv2 `estimateStatistics` is not 
cheap), but the optimization cannot be made complete: deciding which threshold 
applies means reading `stats.isRuntime` first, so the conservative "both 
thresholds off" test is the only sound one. A comment saying that, and a test 
name that says what it actually pins (statistics are not read in these 
configurations), would keep the next reader from treating the 
positive-adaptive-threshold case as a missing condition.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -438,12 +438,26 @@ 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. Do not 
reject the hash
+    // optimization when regular join planning would broadcast the right side, 
as the fallback
+    // would still broadcast it with a slower nested-loop join.
     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 automaticBroadcastDisabled = conf.autoBroadcastJoinThreshold < 0 &&
+          conf.getConf(SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD).forall(_ 
< 0)
+        if (dedicatedThreshold == 0 && automaticBroadcastDisabled) {
+          false
+        } else {
+          canBroadcastBySize(j.right, conf) || (dedicatedThreshold > 0 && {

Review Comment:
   **HIGH**
   
   The floor's rationale is that the fallback would broadcast the right side 
anyway, but the fallback's build side is decided in 
`SparkStrategies.scala:364-433` from the hints: with `BROADCAST` on the left 
and nothing on the right it is `BuildLeft`, and the right side is never 
broadcast. `JoinSuite.scala:1340` pins exactly that configuration, and the 
assertion it replaced was `BuildLeft`. With `autoBroadcastJoinThreshold=100MB`, 
a dedicated threshold of 0, a 1KB hinted left side and a 90MB right side, the 
old plan broadcast 1KB and the new one broadcasts 90MB; the default dedicated 
threshold of -1 is unaffected.
   
   Even when the fallback does build right, the two plans do not ship the same 
bytes. `BroadcastNestedLoopJoinExec` uses `IdentityBroadcastMode` and ships the 
rows as they are, while the `HashedRelation` the hash plan ships keeps a copy 
of the key row per entry (`HashedRelation.scala:477-498`).
   
   Either add a "the fallback really would build right" condition to the floor 
(the test is the negation of `hintToBroadcastLeft(j.hint) && 
!hintToBroadcastRight(j.hint)`, at the cost of reverting the assertions in 
`JoinSuite.scala:1338-1340`), or restate the tradeoff in the comment, the 
config doc and the PR description: the O(M) hash plan is worth a larger 
broadcast even when the fallback would broadcast the left side.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -7452,12 +7452,22 @@ 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 considers the 
right side " +
+        "broadcastable. " +
+        "For join selection, regular planning uses " +
+        "spark.sql.adaptive.autoBroadcastJoinThreshold for runtime statistics 
when it is set, " +
+        "and spark.sql.autoBroadcastJoinThreshold otherwise. The same 
eligibility decision " +
+        "controls whether a null-aware anti join can be pushed below an 
aggregate; this " +
+        "pushdown runs before adaptive execution and therefore uses estimated 
statistics and " +
+        "spark.sql.autoBroadcastJoinThreshold. Thus, zero disables the 
optimization only when " +
+        "automatic broadcasting is also disabled. When neither threshold 
admits the right " +
+        "side, Spark falls back to regular join planning. " +

Review Comment:
   **LOW**
   
   The sentence at :7466-7467, `a nested-loop representation that uses more 
memory`, comes from #58631 and is not supported. On broadcast bytes the hash 
plan is the larger one: `BroadcastNestedLoopJoinExec` uses 
`IdentityBroadcastMode` and returns the rows unchanged 
(`broadcastMode.scala:37-46`), while a `HashedRelation` adds a key-row copy per 
entry or a whole index array serialized at capacity 
(`HashedRelation.scala:477-498`, `:916-932`). Resident object overhead does 
lean the other way (BNLJ keeps an `UnsafeRow` plus an array slot per row), but 
nobody has measured that dimension.
   
   Left in place it tells the reader that admitting the hash plan always saves 
memory over the fallback. This paragraph is already being rewritten, so either 
qualify which kind of memory it means or drop the clause.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/JoinSelectionHelperSuite.scala:
##########
@@ -195,48 +200,144 @@ 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)
+  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 = 20000000, size = Some(20000000))
-    val negativeSizeRight = right.copy(size = Some(-1))
+    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(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))

Review Comment:
   **MEDIUM**
   
   The automatic threshold's inclusive boundary is pinned (`autoThresholdRight` 
is exactly 10MB and :218-219 asserts it is admitted); the dedicated one is not. 
The only two places where the dedicated threshold is the deciding term are this 
block (20000000 bytes against 20MB) and :332 (1000 bytes against 10MB), both 
strictly below the threshold, so changing `rightSize <= dedicatedThreshold` to 
`<` keeps all three suites green.
   
   In `LeftSemiAntiJoinPushDownSuite.scala:172` the right side of the positive 
block is an empty `LocalRelation` whose `sizeInBytes` is 0, which clears any 
nonnegative threshold, so the block cannot show that the automatic threshold is 
what admitted it; the 10MB at :170 could be 1 byte and it would still pass.
   
   Two cases close both: a right side exactly at the dedicated threshold here, 
and a nonzero `StatsTestPlan` right side in the pushdown block, with 
`pushedDownQuery` rebuilt to match 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]

Reply via email to