fix: normalize scalar float sort and window rank keys - #5469
Conversation
b07d581 to
96eafdf
Compare
This is a thorough piece of work. Reusing A few things I would like resolved before merge. No performance data for the sort path
The docs change says the Tracking issue for the nested case Same question for the nested array and struct divergence the docs now call out. There is no issue link in the new Sort-merge join builds an expression it throws away At Silent passthrough in
One test question In |
|
Updated in 38825e26a. The follow-up adds the sort benchmark and links the two remaining compatibility issues from the guide. I checked the six points separately:
The new release benchmark built, all eight smoke cases passed, and benchmark Clippy ( |
|
One thing to consider is https://issues.apache.org/jira/browse/SPARK-54918 which fixes +0, -0 in Spark 4.2.0 |
|
@comphead Thanks for the pointer. I checked the SPARK-54918 patch. It extends normalization to array set operations such as For the scalar sorting and ranking cases here, Spark already treats This PR normalizes the comparison keys consistently across sorting and ranking, so the fix is still needed with Spark 4.2. |
38825e2 to
552fc51
Compare
Why are the changes needed?
A filter such as
RANK() <= 1must keep every row tied for first place. Native execution can currently drop some of those rows when the ordering column contains signed zeros or different NaN representations. This PR fixes that result mismatch for scalarFLOATandDOUBLEkeys, following the nativeWindowGroupLimitExecsupport added in #4870.A rank cutoff can discard a tied row
Consider a table
measurementswhoseDOUBLEcolumnvcontains-0.0,+0.0, and1.0:Spark considers the two zeros equal, so both belong at rank 1. The native comparison instead distinguishes their IEEE-754 representations:
-0.0+0.01.0The query therefore keeps both zero rows in Spark, but only the negative-zero row before this fix.
DENSE_RANKhas the same problem at the cutoff. OnceWindowGroupLimitExechas discarded a qualifying row, the window calculation above it cannot recover that row.NaNs expose the same mismatch. Spark treats every NaN representation as equal and greater than every non-NaN value. For input containing two differently encoded NaNs and
1.0,ORDER BY v DESCfollowed byrnk <= 1should keep both NaNs. Arrow's raw total ordering can separate them; a NaN with its sign bit set can even sort below finite values. The regressions in this PR construct distinct NaN encodings directly so the test actually exercises this distinction.What changes were proposed in this PR?
The fix gives the native sorting and window operators a common representation for comparing scalar floating values. It reuses Comet's existing
NormalizeNaNAndZeroexpression to map every NaN to one canonical NaN and both zero signs to positive zero in the comparison keys. Arrow can then compare those keys using its existing machinery while agreeing with Spark about which values are tied.This normalization must happen before sorting as well as when assigning ranks. For example, with
ORDER BY v, secondary, the rows(-0.0, 1)and(+0.0, 1)are peers and must precede the rows whose secondary key is2. A sort that distinguishes the zero signs can instead produce:Even a corrected peer comparison would see the first peer group split apart. A streaming rank limit could reach the cutoff at
(-0.0, 2)and stop before seeing the other qualifying row. The shared comparison-key construction therefore supplies normalized keys to Sort, Window, and WindowGroupLimit, keeping their ordering and peer decisions consistent.That consistency includes
PARTITION BY, not justORDER BY. An ordinary query such asRANK() OVER (PARTITION BY p ORDER BY id)must still work whenpis aDOUBLEcolumn containing only1.0and2.0. Spark may already have normalizedp; wrapping it again in Sort while leaving Window unchanged produces different expressions for the same key. DataFusion uses those expressions to recognize partition ordering, so the mismatch causes execution to fail even though the values compare equally. The planner therefore reuses already-normalized expressions and constructs matching keys for Sort, Window, and WindowGroupLimit. This preserves ordinary windows as well as the rank-limit path.Native range partitioning uses the same rule for its sampled boundaries. An incoming positive zero and a boundary containing negative zero must compare equal, just as they do during sorting. Normalizing both sides keeps the shuffle's comparisons consistent with the operators that consume its output.
The original input values are preserved: selecting
vstill returns its original zero sign or NaN payload. Only the temporary keys used for comparison are normalized. The scope remains scalarFLOATandDOUBLE; the documented limitations for floats nested in arrays or structs and the existingspark.comet.exec.strictFloatingPointfallback policy are unchanged.How was this PR tested?
The correctness results in the following paragraphs and table were recorded during the earlier implementation and rebase. They are historical local validation, not a fresh full-suite rerun for this review follow-up. The newly run sort benchmark and checks are described after those results.
The regressions verify both the returned rows and the execution path. The Spark cutoff tests require native Sort and WindowGroupLimit operators, compare results with Spark, and check that the input and output retain their original floating bits. Native tests cover compound keys, peer groups spanning batches, and agreement between range boundaries and incoming keys. The previously ignored signed-zero SQL regression is also enabled.
Additional regressions protect ordinary windows partitioned by floating columns:
RANK,PERCENT_RANK, andNTILEmust match Spark for single and compound partitions. A filteredRANKquery also checks the Sort → WindowGroupLimit → Window path. A native planner test verifies that both bare and already-normalized partition keys retain recognizable ordering metadata.With the comparison-key fix removed, the native peer/range regressions and both Spark FLOAT/DOUBLE cutoff regressions fail. In the Spark fixture, unpatched Comet returns 4 rows where Spark returns 8. The fixed runs pass.
On Spark 4.1, the partition-ordering cases were also checked against the library before the key-consistency fix: all six ordinary-window cases failed, while the two WindowGroupLimit controls passed. All eight pass with the fix.
The final review reran all three native planner regressions and the eight Spark 4.1 partition-key regressions; all passed. It also checked the three query shapes behind the Spark SQL CI failures on the earlier revision: a count window partitioned by floating columns, a rank window with computed aggregate/partition keys, and a correlated
EXISTS ... LIMIT 1query that Spark rewrites into a window. Each query matched Spark with native Sort and Window execution asserted. The same probes passed with the pinned base library. These were focused query probes, not reruns of the full CI shards.make core,cargo fmt --all -- --check, andcargo clippy --all-targets --workspace -- -D warningspassed. JVM formatting checks and Spark 4.1 Scala style checks also passed.The review follow-up adds a reproducible scalar floating-key sort benchmark. It executes DataFusion 54.1.0
SortExecwith a bareColumnkey and with Comet'sNormalizeNaNAndZerokey in the same optimized binary, representing the before/after key-construction change for previously bare keys. Already-normalized keys are reused by the planner. This measures execution and output draining for an in-memory, single-partition sort; it does not measure Spark planning, JNI, I/O, or a distributed range shuffle. Input generation and physical-plan construction are outside the timed region.Both variants sort 262,144 rows in 32 batches of 8,192, with one current-thread Tokio runtime, ascending/nulls-last ordering, and no fetch limit. The finite shape has no nulls; the mixed shape has approximately 20% nulls, 10% payload/sign NaNs, 10% signed zeros, and 60% finite values. The release profile uses thin LTO and one codegen unit. Measurements used an AMD EPYC-Milan CPU pinned to one core, Rust 1.98.0, 30 Criterion samples per case, a one-second warmup, and a three-second measurement target. Other workspace builds and tests were paused. A second pass reversed the order of the two variants.
Each cell reports bare → normalized even when execution order was reversed. FLOAT differences were small and the mixed-shape direction changed between passes. DOUBLE normalization had a measurable cost in both passes: +6.6–11.4% for finite values and +5.2–12.5% for mixed values. These are observed repeat ranges, not confidence intervals or end-to-end Spark performance claims. Normalization deliberately changes the comparison semantics for the mixed input. Folding it into sorting or encoding could avoid temporary arrays, but that optimization is not implemented by this correctness fix.
Untimed validation checks every row identity, original floating bits and nulls, ordering, and zero spills. Peak DataFusion pool reservations were identical for bare and normalized keys: 16 MiB / 16.0625 MiB for FLOAT finite/mixed and 18 MiB / 18.0625 MiB for DOUBLE finite/mixed. These are reserved-pool peaks, not process RSS or a complete allocation high-water mark: they do not include the temporary normalized values buffers. Each normalization evaluation allocates 4 bytes per row for FLOAT or 8 for DOUBLE (32/64 KiB for an 8,192-row array), sharing the validity bitmap. That is a per-evaluation size, not total allocation traffic; sorting and merging may evaluate the key more than once.
To reproduce from
native/:For this follow-up, the release benchmark build, all eight benchmark smoke cases, release Clippy for this benchmark with
-D warnings,cargo fmt --all -- --check, andgit diff --checkpassed. The broader correctness suites above were not rerun for this benchmark/documentation-only commit.The guide now links #5506 for narrowing strict-mode admission of corrected scalar sort keys and #5507 for nested ORDER BY/rank compatibility. Spark 4.1.3 diagnostic queries reproduced nested array/struct ORDER BY and array RANK mismatches for both FLOAT and DOUBLE; struct RANK already fell back and matched Spark. All eight strict-mode controls matched Spark, and four scalar planning controls confirmed the conservative strict-mode fallback. Those tracker probes reused a prior native build with the unchanged nested path and matching JVM admission sources; they are not a full rebuild or correctness-suite run of this PR head.
Which issue does this PR close?
Closes #5468.