Repository navigation
fix: choose the forced shuffled hash join at planning time so Spark keeps its sorts - #6785
dwsmith1983 wants to merge 57 commits into
Conversation
…eeps its sorts With spark.comet.exec.forceShuffledHashJoin on, RewriteJoin turned sort-merge joins into hash joins inside CometExecRule, after EnsureRequirements and RemoveRedundantSorts had run. A sort Spark had dropped because the sort-merge join already produced that order was then gone for good, so sortWithinPartitions and SORT BY returned unsorted partitions and partitioned inserts failed with FileAlreadyExistsException. The choice now happens in a planner strategy injected ahead of Spark's JoinSelection, so Spark places and keeps every sort the plan needs. It also runs on each AQE re-plan with the materialized shuffle sizes. The build side size limit and the LeftSemi and ExistenceJoin exclusions carry over. Joins Spark would broadcast, joins with a strategy hint, keys that cannot be sorted, and on Spark 4 collated keys and LeftSingle joins are left to Spark. Closes apache#6770.
|
@andygrove I'd suggest this for |
sunchao
left a comment
There was a problem hiding this comment.
Reviewed the full 10-file diff from 412468e2817207eff0c1907d1c4aac8c3f06680d to 4ffdeba6c9089e0ccf09443f028766eb63aa543c. The PR remains non-draft. No introduced P1/P2 issues found within this review. The existing backport discussion contains no unresolved substantive blocker.
Summary
- Prior state and problem:
RewriteJoinreplaced sort-merge joins after Spark had removed redundant sorts. This could leavesortWithinPartitionsresults unordered and break partitioned inserts. - Design approach: Select
ShuffledHashJoinExecthrough an injected planner strategy, allowing Spark's existing requirement and sort-removal rules to operate on the correct join type. - Correctness: Checked join extraction, residual conditions, build-side eligibility, size boundaries, broadcast deferral, hints, and ordering propagation against Spark sources. Spark's preparation and AQE replanning sequences support the approach. DataFusion's probe-order contract also matches the relevant Spark hash-join ordering claims. No reproducible P1/P2 correctness issue was identified.
- Compatibility analysis: Compared
JoinSelectionHelperandSparkStrategiesacross Spark3.4.3,3.5.9,4.0.4,4.1.3, and4.2.0. The shared helpers retain version-specific build-side rules. The Spark 4 shim correctly excludesLeftSingleand keys lacking binary-stable equality. - Key design decisions: Broadcasts and explicit strategy hints defer to Spark. The existing build-size limit, smaller-side selection, and
LeftSemi/ExistenceJoinrestrictions remain. Declined conversions retain explain information. - Implementation sketch: Register
CometShuffledHashJoinStrategy, remove the late rewrite and ordering-repair pass, add two version-family shims, and expand join regression coverage for sorting, writes, hints, AQE, collations, and disabled modes. - Performance: The native join algorithm is unchanged. The change removes a whole-plan rewrite and ordering-repair traversal, while adding planning-time checks per eligible join. Required output sorts restore correctness. No measured speedup or reproducible performance regression was established.
- Design: Choosing the join before Spark establishes physical requirements addresses the cause of lost sorts. It is simpler to reason about than reconstructing ordering after Spark has erased explicit sorts.
- Abstraction & complexity: The strategy keeps selection policy together, and the small shims isolate genuine Spark-version differences. Reusing Spark's selection helpers avoids duplicating its broadcast and build-side eligibility rules. No complexity concern reached the P1/P2 bar.
- Behavioral changes worth calling out: Strategy hints now win over forcing, non-binary-stable collated keys stay with Spark, plan-only reports omit forced conversion, and eligible static joins inside streaming queries can use Spark's hash join. These are documented. Comparison with
branch-1.1ate9efd9f764ee0a59b7898ff028d6985d4a7a28e1confirms the intended ordering fix. The size limit andExistenceJoinguard already existed in this PR's base. - Suggested improvements: No additional code change meets the P1/P2 reporting bar. Obtain the pending CI and Spark SQL/profile verdicts before queueing this planner change.
Routed skills: Read AGENTS.md and applied .ai/skills/review-comet-pr/SKILL.md. No sibling subsystem skill applies to the changed files. Read the supplied discussion snapshot and checked live reviews and inline comments. No Copilot feedback was used.
Exact-head CI: Label pull requests passed. Comet CI and CodeQL both report action_required. There is no exact-head CI build or test verdict.
Validation: The exact-head Spark 4.1.3 JVM reactor test-compile passed with formatting/style checks skipped. CometConfSuite passed all 21 tests. A disposable planner harness passed 10 checks against the freshly compiled classes, covering build sides, hints, broadcast deferral, the size boundary, disabled Comet, and retention of a local sort through Spark preparation. git diff --check passed.
Validation limits: The planner harness directly exercised planJoin and Spark preparation. It did not execute native joins. Native CometJoinSuite, AQE runtime behavior, partitioned writes, streaming execution, Spark SQL suites, and other-profile builds were not independently run. No matching native build was available, and shared disk exhaustion constrained further preparation. The author's broader test claims were not treated as independently verified results. Tracked project files remain unchanged. Nothing was published.
comphead
left a comment
There was a problem hiding this comment.
Thanks @dwsmith1983. Choosing the join at planning time addresses the cause of the lost sorts, which looks better to me than repairing them afterwards. The inline comments cover reusing Spark's own strategy hint set, and trimming repeated setup and an equivalent case in the new tests.
| Seq(hint.leftHint, hint.rightHint).flatten.flatMap(_.strategy).exists { | ||
| case BROADCAST | SHUFFLE_MERGE | SHUFFLE_HASH | SHUFFLE_REPLICATE_NL => true | ||
| case _ => false |
There was a problem hiding this comment.
Spark already keeps this exact list as JoinStrategyHint.strategies (BROADCAST, SHUFFLE_MERGE, SHUFFLE_HASH, SHUFFLE_REPLICATE_NL, without the AQE-only hints). It is public in hints.scala on 3.4.3, 3.5.8, 4.0.1 and 4.1.3, and ResolveHints uses it to decide which hint names a user can write. Could hasStrategyHint use it, for example Seq(hint.leftHint, hint.rightHint).exists(_.exists(_.strategy.exists(JoinStrategyHint.strategies.contains)))? That drops the four constant imports and the match, and walks the hints once instead of building two intermediate collections. If Spark adds another user-facing hint later, the join would also keep honoring it without another edit here. I have not compiled the suggested form.
There was a problem hiding this comment.
Could
hasStrategyHintuse it
Done. It now checks JoinStrategyHint.strategies, so the four constant imports and the match are gone. I also added a test that a side AQE marks NO_BROADCAST_HASH keeps the hash join, since that hint is outside the set and nothing covered it.
| private def withSortLossConf(adaptive: Boolean)(f: => Unit): Unit = withSQLConf( | ||
| SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> adaptive.toString, | ||
| SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1", | ||
| SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1", | ||
| SQLConf.SHUFFLE_PARTITIONS.key -> "2", | ||
| CometConf.COMET_FORCE_SHJ.key -> "true") { | ||
| withParquetTable((0 until 10000).map(i => (i % 100, i)), "big") { | ||
| withParquetTable((0 until 10).map(i => (i * 10, i)), "small") { |
There was a problem hiding this comment.
This repeats setup the suite already has. big and small are the same data that withChainedJoinTables builds, and the five configs here are the ones withKeptSemiJoinConf and the window test already set (they also set maxBuildSize=-1). Could this helper call withChainedJoinTables(midRows = 3000, midKeys = 100) for the tables, and could the config block become one class-level helper that takes adaptive and serves the semi join, window and sort tests? I have not run it. The unused mid table and maxBuildSize=-1 should not change these plans, since the build side is always small.
There was a problem hiding this comment.
could the config block become one class-level helper that takes
adaptiveand serves the semi join, window and sort tests?
Done. The semi join, window and sort tests now share one helper that takes adaptive, sets the five configs plus maxBuildSize=-1, and builds the tables with withChainedJoinTables(midRows = 3000, midKeys = 100).
| test(s"forceShuffledHashJoin keeps SORT BY on the join key, AQE=$adaptive") { | ||
| withSortLossConf(adaptive) { | ||
| checkPartitionsSortedOverHashJoin(sql(s"$sortLossJoin SORT BY k")) |
There was a problem hiding this comment.
sql(sortLossJoin).sortWithinPartitions("k") and sql(s"$sortLossJoin SORT BY k") should analyze to the same Sort with global = false over the same projection and join. In Spark 3.5.8 AstBuilder builds SORT BY as Sort(..., global = false, query) and Dataset.sortInternal builds the same node. From reading the code I expect identical plans and identical assertions here, but I have not compared the plans. The issue lists both forms, so I see why both are here. Would one of them be enough, given that the second adds a run per AQE setting without a different plan?
There was a problem hiding this comment.
Would one of them be enough, given that the second adds a run per AQE setting without a different plan?
Yes. Both forms analyze to the same Sort with global = false, so I dropped the SORT BY test and kept the sortWithinPartitions one.
|
Thanks @dwsmith1983. I added the backport label. I won't review yet since there are existing review comments to be addressed. |
…broadcast stage check hasStrategyHint now uses Spark's own set of strategy hints. The forced hash join tests share one helper, and the SORT BY case is dropped since it plans the same local sort as sortWithinPartitions. New coverage: a side AQE marks NO_BROADCAST_HASH keeps the hash join, and the planned broadcast test now fails without the broadcast stage check, which keeps AQE from discarding a re-plan that hash joins the stage.
…in chain Chain two forced shuffled hash joins on the same key with the join dynamic filter off and on. With it on, the outer join's build keys filter the big side below the intermediate join, so both joins see the same row count and the early filter prunes the keys the outer build lacks.
# Conflicts: # spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
sunchao
left a comment
There was a problem hiding this comment.
Reviewed commit fddeee31fcaaed6816f1153522023caf1298226b. New P1/P2 findings are detailed inline.
| planBySpark(join)(smj => withInfo(smj, declineReason(reason))) | ||
| case None => | ||
| Seq( | ||
| ShuffledHashJoinExec( |
There was a problem hiding this comment.
[P1] Preserve cached probe ordering before exposing the hash join’s ordering to Spark. With forcing enabled and AQE/broadcasts disabled, materialize a probe using .repartition(2, col("k")).sortWithinPartitions("k", "v").cache(), left-join a smaller table containing only some probe keys, then request .sortWithinPartitions("k", "v"). Selecting ShuffledHashJoinExec here lets Spark remove the final sort because it advertises the probe’s entire ordering. Comet reads the cache through native ScanExec, which carries no ordering metadata. The build-right left outer join becomes DataFusion’s Right join, whose unordered-input path appends unmatched probe rows after matched rows. The result therefore violates the requested ordering. This is introduced by the earlier selection: the exact base retains the final (k, v) sort because the original sort-merge join advertises only k. Please preserve ordering through the native input/outer join, or retain the sort/fallback until that guarantee is available, and add a cached-probe regression test.
Evidence: Spark 4.1.3 differential harness: probe range(2000).selectExpr("id % 100 as k", "id as v").repartition(2, col("k")).sortWithinPartitions("k", "v").cache() followed by materialization; build range(10).selectExpr("id * 10 as k"); left outer join followed by sortWithinPartitions("k", "v"). Applying the exact-base rewrite leaves one final Sort [k, v]; the exact-head strategy leaves none. Separately, native DataFusion HashJoinExec with build keys [1,3], ordered probe keys [0,1,2,3,4], and no ordering metadata returned [1,3,0,2,4]. Its reproduced 55.1.0 hash_join/exec.rs, hash_join/stream.rs, and joins/utils.rs are byte-identical to pinned 55.2.0. Current Comet ScanExec::new initializes EquivalenceProperties without ordering. Reproduction files and logs are in /tmp/review6785-fdde-probe-v6vo_b5b/. Full Comet native execution was not available.
There was a problem hiding this comment.
Please preserve ordering through the native input/outer join, or retain the sort/fallback until that guarantee is available, and add a cached-probe regression test.
Confirmed. With forceShuffledHashJoin the planner now picks ShuffledHashJoinExec, which reports the cached probe's (k, v) ordering, so Spark drops the final sort. Comet reads the cache through a scan with no ordering, and DataFusion's Right join puts the unmatched probe rows after the matched ones in each batch. I reproduced it with a cached repartition(2, k).sortWithinPartitions(k, v) probe left-joined to a 5-key table. Every partition comes back out of order, with the right rows.
The same native gap is already reachable on main without this conf, through a SHUFFLE_HASH hint or a broadcast hash join. Both fail that test on main. #6835 fixes it at the source: it declares the streamed order on the probe for these two outer shapes and follows the cache scan into the cached plan. With its non-test changes on top of this branch, the forced, hinted and broadcast cases all pass.
So I'd like #6835 to land first. I'll then merge it into this branch and add the cached-probe test for the forced and hinted joins. cc @andygrove, since this puts #6785 after #6835.
- Build the forced hash join on the side AQE's PREFER_SHUFFLE_HASH hint names when only one buildable side carries it. - Run a broadcast nested loop join in Spark when Spark reports an output order for it: the native join emits rows build row by build row, so a sort Spark dropped on the strength of that order was lost (apache#6898). - Declare the streamed order on hash joins that keep their probe order when Comet can check it, so a native window above sees it, and let the join dynamic filter walks pass through SortedInputExec so reader pruning still applies below the declared order.
…dered With the forced shuffled hash join planned before Spark places sorts, the join reports its streamed side's order and Spark drops the sort below a window. When that side is a checkpoint, Comet cannot verify the order and declares none, so the native window rejected its input with "Expects PARTITION BY expression to be ordered". Hinted joins and windows directly over a checkpoint failed the same way. The window now sorts its input by the partition keys, then the order keys, whenever its input does not show every partition key as ordered. That is the condition the window rejects, so plans that ran before are unchanged. The sort is reported with the window's metrics.
Joins are no longer rewritten after planning, so the make_date rule now leaves a tagged join as Spark's ShuffledHashJoinExec. Add a join test for that path, drop the forced hash join settings from the rule suite test that no longer reach a rewrite, and fix the preserve doc comment.
…anner-strategy # Conflicts: # spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
…-force-shj-planner-strategy
…anner-strategy # Conflicts: # docs/source/user-guide/latest/operators.md # spark/src/test/scala/org/apache/comet/exec/CometJoinSuite.scala
…anner-strategy # Conflicts: # native/core/src/execution/operators/sorted_input.rs # native/core/src/execution/planner/window.rs # spark/src/test/scala/org/apache/comet/exec/CometWindowExecSuite.scala
When Comet cannot vouch for the order a window's input reports, the native window sorted that input only by its PARTITION BY and ORDER BY keys. Spark had already planned the operators above the window around the longer order, so a second window ordered by more keys, or a sort Spark removed as redundant, saw rows out of order. The window is now sent the whole reported order, led by its own keys, and restores it unless the input already has it. It falls back to Spark when that order has a key Comet cannot serialize or a collated string key. Planning fails if a window's input is not ordered on every PARTITION BY key, or if both order fields are set.
|
Moved back to draft until #7555 merges. It fixes the same lost order for windows that stay in Spark, and this PR's Spark fallback for windows relies on it. The latest push makes a native window over an order Comet can't verify restore the whole order its input reports, not only its own keys. Two stacked windows over a sorted checkpoint, the upper one ordered by more keys, numbered rows wrongly before. |
Which issue does this PR close?
Closes #6770.
Closes #6898.
#7555 should merge first: it restores the same order for windows that stay in Spark, and this PR's Spark fallback for windows relies on it.
Rationale for this change
With
spark.comet.exec.forceShuffledHashJoinon, sort-merge joins were turned into hash joins after Spark had removed the sorts they made redundant, and nothing put them back. Sorting within partitions on the join key returned unsorted rows, and a partitioned insert keyed on it failed withFileAlreadyExistsException.What changes are included in this PR?
PREFER_SHUFFLE_HASHhint.MERGEnow wins over the force config.branch-1.1andbranch-1.2have the same rewrite, so a backport needs this strategy rather than a cherry-pick.How are these changes tested?
New
CometJoinSuitetests, with AQE off and on, check that sorts and partitioned inserts on the join key keep their order. Others cover hints, broadcasts, skew, dynamic join filters, collated keys, plan-only mode, the AQE build side hint, nested loop joins over ordered inputs and a native window over an inner join of a sorted cache, and the existing size limit tests now run against the strategy. Two run the forced join over a sorted cache and a sortedlocalCheckpointand check that both outer join shapes keep the(k, v)order. Two more run a native window over a forced or hinted inner join of a sorted checkpoint and directly over the checkpoint. Native tests cover the declared order on inner joins and the dynamic filter crossing it.window_input_order.rscovers when the window adds the sort, the order it restores, the planning errors for an input not ordered on its partition keys, and that its time is reported.CometWindowExecSuiteruns stacked windows and a droppedsortWithinPartitionsover sorted checkpoints at 2,000 and 200,000 rows, descending and nulls-last orders, a float key with -0.0 and 0.0, a partition key the input reports through an alias or the other key of an inner join, and both fallbacks. A forced join with amake_datecondition checks that it stays a Spark shuffled hash join with the make_date fallback reason.