feat: support WindowGroupLimitExec - #4870
Conversation
14ee3bb to
d781362
Compare
| : : : +- CometWindowGroupLimitExec | ||
| : : : +- CometSort | ||
| : : : +- CometFilter | ||
| : : : : +- Subquery |
There was a problem hiding this comment.
Interesting, we dont support Subquery? 🤔
|
Up front: I used an LLM to help work through this review, so please push back on anything that looks wrong or off base. Thanks for taking this on. I traced the rank arithmetic in A few things I would like to see addressed.
|
|
Thanks @andygrove addressed in 7ef79eb |
| let _timer = self.baseline_metrics.elapsed_compute().timer(); | ||
| self.process_batch(&batch) |
There was a problem hiding this comment.
[P1] Release the metrics borrow before calling process_batch
Could we clone elapsed_compute() into a local before creating this timer? ScopedTimerGuard holds an immutable borrow of self.baseline_metrics until the end of this block, while self.process_batch(&batch) requires a mutable borrow of self. Compiling this file at 7ef79eb0 produces E0502: cannot borrow self as mutable because it is also borrowed as immutable on the process_batch call. This prevents native builds even when WindowGroupLimit is disabled. The preceding add9758e version compiles against the same dependencies.
This small change compiles while preserving the timing:
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let _timer = elapsed_compute.timer();
self.process_batch(&batch)| val partitionProtos = fields.partitionSpec.map(e => e -> exprToProto(e, childOutput)) | ||
| val orderProtos = fields.orderSpec.map(e => e -> exprToProto(e, childOutput)) |
There was a problem hiding this comment.
[P2] Reject collated keys before native window-group pruning
Could we reject non-default string collations on these keys before lowering the operator? For an ordinary, uncollated Parquet table containing (grp, s) = (1, 'A'), (1, 'a'), (1, 'b'), this window expression filtered to rk <= 1 must retain both A and a:
RANK() OVER (
PARTITION BY grp
ORDER BY CAST(s AS STRING COLLATE UTF8_LCASE)
) AS rkThe collated key is serialized as ordinary UTF8, and the new limiter compares its row-encoded bytes, so it drops a. DENSE_RANK has the same problem.
The collated-scan fallback does not protect this case because the stored column is plain STRING. The cast stays in a native projection through the default codegen dispatcher, and the required two-key [grp, cast-result] sort passes supportedSortType, which only checks collations in its single-key branch. This particular A, a, b sequence is correctly sorted under both binary and case-insensitive ordering, so the lost row is not explained by an existing sort-order mismatch. The conversion path also permits this with spark.comet.exec.window.enabled=false, and a later Spark Window cannot restore a row already discarded by native Partial WGL.
I verified that Spark 4.0.1 returns both peers and that the isolated native Rank and DenseRank operators each retain only A. The native probes used a diagnostic copy with only the timer-borrow compilation issue corrected, not an end-to-end run of the unchanged head. Please gate collated key types, including nested collated types, until peer equality preserves Spark's collation semantics.
sunchao
left a comment
There was a problem hiding this comment.
One additional correctness issue at 77702dba, reproduced against the unchanged native operator. Details and the minimal regression case are inline.
| if self.partition_exhausted { | ||
| mask_builder.append(false); | ||
| continue; |
There was a problem hiding this comment.
[P1] Record skipped rows before taking the prefix shortcut
Could we set first_dropped_at when this branch drops a row? If a batch starts inside a partition exhausted by the previous batch, its leading drops are not recorded. With fetch = 1 and sorted (partition, order) rows:
Batch 0: (1,10), (1,20), (1,30), (1,40)
Batch 1: (1,50), (2,10), (2,20), (3,10)
Expected output: (1,10), (2,10), (3,10)
Actual output: (1,10), (1,50), (2,10)
Batch 1 builds the correct mask [false, true, false, true], but records first_dropped_at = 2 and kept = 2. The shortcut at lines 419–420 therefore returns batch.slice(0, 2) instead of applying that mask. This permanently loses partition 3's top row, which a subsequent Final limiter or window/filter cannot recover.
I reproduced this for ROW_NUMBER, RANK, and DENSE_RANK by compiling and executing the unchanged rank_limit.rs from 77702dba in an isolated harness with DataFusion 54.1.0 and Arrow 58.4.0. The six existing tests pass and all three regression cases fail. Recording the first dropped position in this branch makes all nine pass. Could we include this mixed-partition batch case in the native tests?
There was a problem hiding this comment.
Checking this, not sure if we can have also SQL test for this, but let me try
pingz-oai
left a comment
There was a problem hiding this comment.
Reviewed exact commit 77702dba39c8472094bf55d596f8b0365684c024.
The existing P1 batch-prefix row-loss report remains reproducible. I independently executed the unchanged rank_limit.rs with DataFusion 54.1.0, Arrow 58.4.0, and Futures 0.3.33: all six existing tests pass, while the reported regression fails for ROW_NUMBER, RANK, and DENSE_RANK. A separate Spark 4.1.3 baseline returns the expected three groups. Recording the first dropped position in the exhausted-partition branch in a diagnostic copy makes all nine native tests pass.
The merge-base version leaves WindowGroupLimit in Spark; this PR enables the faulty native path by default. I am withholding approval for that P1. Its existing inline thread already contains the triggering batches, incorrect result, and affected lines, so I have not duplicated the inline finding. No additional reproducible P1/P2 regression found.
ziting-openai
left a comment
There was a problem hiding this comment.
Re-reviewed the current head: exhausted cross-batch partition prefixes now record every dropped row, regression coverage spans ROW_NUMBER/RANK/DENSE_RANK, and non-default collated partition/order keys correctly fall back.
pingz-oai
left a comment
There was a problem hiding this comment.
Reviewed exact commit 8d2ea9830d1b5e0898ac43f349cd835343b7501d.
The previously reported cross-batch row-loss regression is fixed. The unchanged native source passes all 7 current unit tests, all 3 prior focused regression probes, and a 6,912-case matrix with zero mismatches using the pinned Arrow 58.4.0 / DataFusion 54.1.0 dependencies. I also traced Spark version shims, ordering/distribution, plan identity, collation fallback, and optimizer safety conditions. No remaining P1/P2 findings.
Validation included 22 Spark 4.1.3 plan/semantics probes and isolated native execution; a full Comet/Spark integration build was not run.
Reviewed by Codex on behalf of pingz-oai.
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed commit 8d2ea9830d1b5e0898ac43f349cd835343b7501d with five fresh independent reviewers. The earlier compilation, collation fallback, plan-identity, and cross-batch row-loss issues are addressed. No new, unique P1/P2 findings.
Validation included all seven current native unit tests, three earlier regression probes, and a 30,720-case native matrix. Full Comet integration was not run locally. The existing floating-point peer-equality limitation remains.
CI is still incomplete: the macOS Spark 4.0 scans job hit a native thread-cleanup SIGSEGV after a passing test. The available evidence does not establish whether this PR caused that crash.
|
Thanks @sunchao for the review! |
Which issue does this PR close?
Closes #4837 .
Rationale for this change
Adds native support for Spark's
WindowGroupLimitExec(SPARK-37099, Spark 3.5+), which computes per-partition top-K forROW_NUMBER,RANK, andDENSE_RANK.PartitionedRankLimitExec(native/core/src/execution/operators/rank_limit.rs) does a single streaming pass over the child sorted by[partition_keys..., order_keys...], matching Spark'sSimpleLimitIterator/RankLimitIteratortie semantics exactly.native/core/src/execution/planner.rs) routesROW_NUMBERwithoutPARTITION BYtoLocalLimitExec(the sorted child makes first-K equivalent to top-K); every other combination goes toPartitionedRankLimitExec.WindowGroupLimitand enumRankLikeFunction(native/proto/src/proto/operator.proto).CometWindowGroupLimitExecplus per-versionShimCometWindowGroupLimit(no-op on 3.4, real on 3.5 / 4.0 / 4.1). Registration inCometExecRule.nativeExecsis gated on the shim so the 3.4build stays clean.
spark.comet.exec.windowGroupLimit.enabled(defaulttrue) viaCometConf.COMET_EXEC_WINDOW_GROUP_LIMIT_ENABLED.RemoveRedundantWindowGroupLimitsSuiteto also countCometWindowGroupLimitExec.spark/src/test/resources/sql-tests/expressions/window/coverROW_NUMBER/RANK/DENSE_RANK, datatypes, edge cases, and scalar-subquery-shaped rewrites.