dongjoon-hyun commented on PR #58870:
URL: https://github.com/apache/spark/pull/58870#issuecomment-5779406953

   Thanks for the follow-up. I went through this in detail; notes below, 
grouped by severity. Line numbers are against `64b6d905ec4`.
   
   ## Correctness / design
   
   **1. The optimizer and AQE can now disagree, and the Aggregate pushdown is 
not reversible** — `joins.scala:463`
   
   Routing the decision through `canBroadcastBySize` makes it depend on 
`stats.isRuntime`, and the same predicate gates two things at two different 
times:
   
   - `PushDownLeftSemiAntiJoin.scala:68` — logical optimizer, estimated stats, 
`isRuntime = false`, so `spark.sql.autoBroadcastJoinThreshold`;
   - `JoinSelection` under AQE — `LogicalQueryStage` runtime stats, `isRuntime 
= true`, so `spark.sql.adaptive.autoBroadcastJoinThreshold`.
   
   With `broadcastThreshold=0`, `autoBroadcastJoinThreshold=10MB`, and a right 
side estimated at 5MB but 50MB at runtime (or with the adaptive threshold set 
lower), the optimizer pushes the NAAJ below the `Aggregate` — `pushDownJoin` 
makes the join's left input `agg.child`, i.e. pre-aggregation rows — and then 
`reOptimize` re-runs `JoinSelection`, rejects the hash NAAJ on runtime stats, 
and lands on `BroadcastNestedLoopJoinExec` over that enlarged left side. 
`AQEOptimizer.defaultBatches` doesn't include `PushDownLeftSemiAntiJoin`, so it 
can't be undone.
   
   The window looks structural rather than incidental: `reOptimize` 
(`AdaptiveSparkPlanExec.scala:407`) runs before `createQueryStages(..., 
firstRun = false)`, so whenever the right side contains a shuffle there is an 
iteration where the right child is a `LogicalQueryStage` over a materialized 
*shuffle* stage. `LogicalQueryStageStrategy`'s null-aware case doesn't match 
that, so the plan reaches `SparkStrategies.scala:343` with `isRuntime = true`.
   
   On master this couldn't happen — the old predicate never read `isRuntime`, 
so both call sites always agreed. The new doc describes the two-clock situation 
in prose, but nothing guards it.
   
   **2. The floor doesn't cover the case where the fallback broadcasts the 
right side anyway** — `joins.scala:464`
   
   For `LeftAnti`, `canBuildBroadcastLeft` is false, so `desiredBuildSide` is 
`BuildRight`, and `createJoinWithoutHint`'s final `getOrElse` 
(`SparkStrategies.scala:418-423`) forces a `BroadcastNestedLoopJoinExec` on the 
right side with no size check at all.
   
   So with no hints, `broadcastThreshold=0`, `autoBroadcastJoinThreshold=10MB`, 
left 200GB and right 50MB: `canBroadcastBySize` is false, the hash NAAJ is 
declined, `createBroadcastNLJoin(false)` returns `None`, cartesian product 
doesn't apply to `LeftAnti` — and the same 50MB is broadcast anyway, as 
`IdentityBroadcastMode` (every column, not a single-key `HashedRelation`), with 
the probe going from O(M+N) to O(M*N).
   
   This is pre-existing rather than introduced here, and the new doc text 
acknowledges it. But it means the floor closes only the `dedicated < size <= 
auto` band, while the configuration still cannot prevent the broadcast in the 
case where it would hurt most — it can only make it worse. Worth deciding 
explicitly whether that's the intended contract. There's also no test for the 
unhinted `AUTO` + `dedicated=0` combination.
   
   **3. `NO_BROADCAST_AND_REPLICATION` is honored at threshold 0 and ignored 
above it** — `joins.scala:465`
   
   `!hintToNotBroadcastAndReplicateRight(j.hint)` guards only the disjunct at 
line 461. The `dedicatedThreshold > 0` disjunct at 465 bypasses it:
   
   ```scala
   rightBroadcastSelectedByHintOrSize ||
     (dedicatedThreshold > 0 && {
       val rightSize = j.right.stats.sizeInBytes
       rightSize >= 0 && rightSize <= dedicatedThreshold
     })
   ```
   
   With `broadcastThreshold=20MB` and a 5MB right side carrying the hint, line 
461 is false but 465 is true, so a `BroadcastHashJoinExec` broadcasts and 
replicates exactly the side the hint forbids. Set the threshold to 0 and the 
same plan is rejected. That contradicts the comment added at line 444 ("a 
right-only no-broadcast-and-replication hint makes the fallback build the left 
side instead").
   
   Symmetrically, `getBroadcastNestedLoopJoinBuildSide` returns 
`Some(BuildRight)` when the *left* side carries the hint, which the predicate 
can't see either.
   
   Latent today — `hintAliases` is `Set.empty` and the only producer 
(`RewriteMergeIntoTable.scala:427`) sets it on the left of an inner join — so 
this is a trap rather than a live bug, but the asymmetry reads as an oversight.
   
   **4. For hint-free plans the whole predicate is `max(auto, dedicated)`** — 
`joins.scala:448`
   
   Expanding lines 448-470 with no hints gives `(rightSize <= auto) || (D > 0 
&& 0 <= rightSize <= D)`, i.e. `rightSize <= max(auto, D)` for `D > 0` and 
`rightSize <= auto` for `D == 0` — differing only at the degenerate point `auto 
< 0, D == 0, rightSize == 0`.
   
   So the 23 lines of nested branching and the 243-word doc exist entirely to 
buy hint sensitivity, and per (2) and (3) that hint sensitivity is itself 
incomplete. Given this is the fourth commit rewriting the same predicate 
(7090c70cfef → f8b673c5a53 → 6456b573343 → this one), each time changing the 
documented contract — the immediately preceding one said "Join hints do not 
override this configuration", which this PR reverses — and `.version("4.2.1")` 
is still unreleased, it may be worth settling it now: either state the rule as 
`effective = max(auto, dedicated)` and drop the hint terms, or delegate to a 
helper shared with `createBroadcastNLJoin` / 
`getBroadcastNestedLoopJoinBuildSide` so the two copies of the build-side rule 
can't drift.
   
   **5. The method scaladoc now contradicts the branch it governs** — 
`joins.scala:419`
   
   Lines 419-420 still say `None` is returned "when one is ruled out by the 
join shape or by a hint", and 425-426 document the precedence as "a hinted 
broadcast first, a hinted shuffle hash join as a veto, then the sizes". After 
this change the NAAJ branch returns `None` purely from sizes and thresholds 
with no hint involved, and treats `BROADCAST(left)` as a veto of a right-side 
broadcast — which appears nowhere in that order. The inline comment 10 lines 
below was updated but the scaladoc wasn't.
   
   This isn't cosmetic: `PushDownJoinThroughUnion.scala:113` explicitly cites 
this contract when justifying that `getBroadcastHashJoinBuildSide` alone 
"carries the requirement the removed `canPlanAsBroadcastHashJoin` conjunct used 
to". Someone widening that rule's `Inner || LeftOuter` guard to `LeftAnti` 
would read "None only by shape or hint" and inherit a right-side-duplicating 
rewrite whose correctness now turns on two broadcast thresholds.
   
   **6. The config doc's first sentence contradicts the new semantics** — 
`SQLConf.scala:7451`
   
   "Configures the maximum estimated size in bytes ... for which Spark uses the 
broadcast hash join optimization" is no longer true in either direction:
   
   - with `autoBroadcastJoinThreshold=30MB` and `broadcastThreshold=20MB`, a 
25MB right side is admitted — any dedicated value below the automatic threshold 
is inert;
   - a user who sets the threshold to 1MB to cap the NAAJ `HashedRelation` 
build gets a 10GB right side built anyway as soon as the query carries `/*+ 
BROADCAST(right) */`, since `rightBroadcastHint` short-circuits before any size 
check.
   
   Neither is covered: `JoinSelectionHelperSuite.scala:246-257` checks 
`overDedicatedThresholdRight` without hints, and applies `hintBroadcast` only 
to the left.
   
   ## Tests
   
   **7.** `JoinSuite.scala:1311` — the rewritten test hand-rolls `plan.collect` 
blocks instead of using `assertJoin` (`JoinSuite.scala:64`), which asserts 
`canPlanAsBroadcastHashJoin(optimized, conf) === 
operators.head.isInstanceOf[BroadcastHashJoinExec]` ("not in sync with join 
selection codepath!") and, for a BHJ, that 
`getBroadcastHashJoinBuildSide(...).contains(bhj.buildSide)`. That is exactly 
the invariant this PR changes, and it's the one assertion the new test drops. 
The SPARK-32290 NAAJ test at line 1249 already uses this pattern. It would also 
collapse three near-identical 14-line blocks (18 prefix-distinguished locals) 
to about three lines each.
   
   **8.** `LeftSemiAntiJoinPushDownSuite.scala:146` — this is the only NAAJ 
test in the repo that doesn't pin `SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN`. 
`ExtractSingleColumnNullAwareAntiJoin.unapply` reads 
`SQLConf.get.optimizeNullAwareAntiJoin` — the session conf, not the one passed 
in. If that flag is ever false, the extractor stops matching, and the two 
`comparePlans(Optimize.execute(x), x)` negatives at lines 189 and 196 still 
pass while only the positive fails, so the failure points at the threshold 
logic rather than at the flag.
   
   **9.** `JoinSelectionHelperSuite.scala:246` — two changed behaviors have no 
assertion: a right `BROADCAST` hint overriding a positive dedicated threshold 
(the bypass in (6)), and the `BigInt` size vs `Long` threshold comparison. The 
surviving `overLongMaxRight` case at 281-292 runs only with `dedicatedThreshold 
< 0`, which returns `true` at line 449 before any comparison happens — so a 
"simplification" of line 467 to `rightSize.toLong <= dedicatedThreshold` would 
truncate `Long.MaxValue + 1` to `Long.MinValue` and broadcast a 9-exabyte right 
side, uncaught. Both are one assertion each.
   
   **10.** `JoinSelectionHelperSuite.scala:295` — `RuntimeStatsPlan` hardcodes 
`isRuntime = true`, which no leaf can be in production: `Statistics(isRuntime = 
true)` comes only from `QueryStageExec.computeStats` and reaches a logical plan 
only via `LogicalQueryStage.computeStats`, which falls back to `isRuntime = 
false` while the stage is unmaterialized. So the ordering that matters under 
AQE — the first planning pass, where `canBroadcastBySize` applies the 
*non-adaptive* threshold — is untested, and the end-to-end `JoinSuite` test 
runs entirely under `ADAPTIVE_EXECUTION_ENABLED = false`. Adding `isRuntime: 
Boolean = false` to `StatsTestPlan` would fix the modeling gap and make it 
reusable — it's used in 177 places and none can currently test runtime stats.
   
   **11.** `JoinSelectionHelperSuite.scala:328` — the `ThrowingStatsPlan` test 
pins the `&&`/`||` evaluation *order* rather than behavior. Two of its 
assertions pass only because `!hintToBroadcastLeft(j.hint)` is written before 
`canBroadcastBySize(...)` on line 463, and because `rightBroadcastHint ||` 
precedes it on 462. Rewriting 463 as `canBroadcastBySize(j.right, conf) && 
!hintToBroadcastLeft(j.hint)` — semantically identical — fails the suite with 
`IllegalStateException: statistics should not be read`.
   
   ## Cleanup
   
   **12.** `joins.scala:454` — the `dedicatedThreshold == 0 && 
automaticBroadcastDisabled && !rightBroadcastHint` branch appears to be a 
behavioral no-op. Under that guard `canBroadcastBySize` picks 
`ADAPTIVE.getOrElse(auto)` or `auto`, both negative by the guard, so 
`sizeInBytes >= 0 && sizeInBytes <= threshold` is unsatisfiable; and 
`dedicatedThreshold > 0` is false. The `else` branch yields `false` 
identically. It only avoids one memoized `stats` read, at the cost of lines 
452-453 being a second copy of the adaptive-vs-static selection rule that 
already lives in `canBroadcastBySize` (364-369) — if that ever changes, this 
guard drifts with no compile error. It also never fires on a default cluster, 
since `AUTO_BROADCASTJOIN_THRESHOLD` ships as `"10MB"`, so the "avoid 
potentially expensive statistics computation" comment doesn't hold for the 
configuration this PR is about.
   
   **13.** `joins.scala:452` — `val` is eager, so 
`conf.autoBroadcastJoinThreshold` is read on every invocation with a 
nonnegative threshold although it's consumed only by the `dedicatedThreshold == 
0` conjunct on 454. That read goes through 
`ConfigEntryWithDefaultString.readFrom`, which unconditionally runs 
`byteFromString` → `JavaUtils.byteStringAs`: a 
`toLowerCase(Locale.ROOT).trim()` plus two `Matcher` allocations and a regex 
match, even when unset. With `dedicatedThreshold == 0` and auto broadcasting on 
it happens twice per call (452, and again inside `canBroadcastBySize` at 368); 
master did zero. Inlining both conf reads as the trailing conjuncts of the `if` 
at 454 lets `&&` short-circuit them, without the `LazyRef` a `lazy val` would 
allocate.
   
   **14.** `SQLConf.scala:7455` — the doc hardcodes four config key literals 
where the file's convention is `${ENTRY.key}` interpolation (98 occurrences, 
including `ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD` itself at line 1527 
referencing `${AUTO_BROADCASTJOIN_THRESHOLD.key}`). All three entries are 
defined earlier, so `.doc(s"...")` compiles as is, and a key rename becomes a 
compile error rather than silently wrong docs. On length: across the ~800 
`.doc` strings in this file the median is 29 words and p99 is 135; this one is 
243, the longest in the file, on an `internal()` config that never reaches the 
published docs. There's also a stray `"uses " +` fragment alone on line 7460 — 
the split is needed for the 100-char limit, but could sit at a phrase boundary.
   
   **15.** `JoinSuite.scala:1311` — renaming the test from `SPARK-36082:` to 
`SPARK-59673:` drops the regression marker from a test that still contains the 
SPARK-36082 guard: the first `withSQLConf` block (1327-1357: 
`AUTO=Long.MaxValue`, `dedicated=0`, left `BROADCAST` hint, asserting 
`BroadcastNestedLoopJoinExec` with `buildSide === BuildLeft`) is that guard 
verbatim. `grep -c SPARK-36082 JoinSuite.scala` gives 4 on master and 3 here, 
so the PR description's `-z SPARK-36082` command and its "4 matching 
SPARK-36082" count no longer reproduce — and the tests this PR actually changed 
aren't selected by that filter at all. Keeping both IDs in the title would fix 
both.
   
   ---
   
   (1) and (2) are the ones I'd want resolved before merge; the rest are 
smaller. Happy to be wrong on any of these — particularly (2), where the 
remaining nested-loop path may well be the deliberate contract you describe in 
the doc, in which case it'd be good to say so explicitly.
   
   Generated-by: Claude Opus 5
   


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