avantgardnerio opened a new pull request, #2294:
URL: https://github.com/apache/datafusion-ballista/pull/2294

   **This PR is purely additive.** Nothing in the tree constructs any of it, 
`RuntimeStatsExec` is untouched, and T-Digest is neither modified nor removed. 
`main` behaviour is byte-for-byte identical. It is the foundation for widening 
`RuntimeStatsExec` past the single non-nullable `Float64` sketch it accepts 
today, split out so the widening itself can be reviewed on its own.
   
   ## What it adds
   
   `ballista/core/src/sort_key.rs`:
   
   - **`SortKeyCodec`** is the complete ordering spec for one fixed-width 
`ORDER BY` key: its arrow type, its direction, and where its NULLs sort. It 
encodes values to an order-preserving `u64` and back. Covers signed and 
unsigned integers, `Float32`/`Float64`, and the temporal types that are `i32` 
or `i64` underneath. Returns `None` for anything else, which is the signal to 
fall back to arrow-row.
   - **`SortKeySketch`** pairs that codec with a `KllSketch<u64>` over the 
encoded values and a count of the NULLs, and owns `ingest` / `merge` / 
`quantile` / `cuts` / `min` / `max`.
   
   `ballista/core/src/kll.rs` gains `min()`, `max()`, `count()`, `at_rank()`, 
`Clone` and `Debug`.
   
   `benchmarks/benches/quantile_sketch.rs` gains arms that measure the shipped 
path rather than an open-coded stand-in.
   
   ## Why
   
   Two things the current sketch cannot express.
   
   ### NULLs have no representation
   
   T-Digest has no NULL slot, and no sentinel `f64` can serve as one: `±inf` 
collides with real infinities, and there is nothing else outside the value 
range. `RuntimeStatsExec::try_new` therefore rejects nullable routing 
expressions outright, and `split_batch_by_range` sends NULL keys to partition 0 
regardless of what `nulls_first` says (there is a TODO to that effect at 
`range_repartition_common.rs:162`).
   
   `SortKeySketch` counts NULLs beside the sketch instead of encoding them. A 
NULL has no position among the values, only a side, and `nulls_first` fixes 
that side at plan time. Keeping it out of the key also leaves the key 8 bytes 
wide instead of 16, which the benchmark prices at 1.19x against 1.58x.
   
   The cost is that a rank over the population is no longer a rank over the 
values, so the NULL run has to be stepped over. That remap lives in 
`SortKeySketch::quantile` and nowhere else, which is the main reason the type 
exists rather than a loose `(sketch, null_count)` pair.
   
   ### A sketch can lose its extremes
   
   `tdigest.rs:210-215` reads a batch's extremes from `first()` and `last()` 
only, then folds them with `f64::min` / `f64::max`, which are the NaN-ignoring 
variants:
   
   ```rust
   let maybe_min = *sorted_values.first().unwrap();
   let maybe_max = *sorted_values.last().unwrap();
   if self.count() > 0.0 {
       result.min = self.min.min(maybe_min);
       result.max = self.max.max(maybe_max);
   ```
   
   Sorted by `total_cmp`, any batch containing a NaN has NaN at one or both 
ends. That element is then discarded by the fold, and because nothing between 
the ends is examined, the batch's real extremes go with it:
   
   ```
   input        : [1.0, 5.0, NaN, -NaN, +inf, -inf]
   total_cmp    : min=NaN  max=NaN   <- the true extremes
   one batch    : min=NaN  max=NaN
   split batches: min=1.0  max=5.0
   split, no NaN: min=-inf  max=inf
   ```
   
   The last line is the control: same two-batch split without the NaN, and both 
infinities survive. Reproduction is four lines against 
`datafusion-functions-aggregate-common` alone.
   
   This matters because `cut_partitions` routes shuffle files by exactly 
`[sketch.min(), sketch.max()]`. A file reporting `[1.0, 5.0]` while spanning 
`[-inf, +inf]` is routed only to buckets overlapping 1 to 5, and the rest of 
its rows are not routed anywhere. There is no error, and since the outcome 
depends on which batch arrived first it will not reproduce consistently.
   
   `KllSketch` has no equivalent: `absorb_slice` scans every element using 
`Ord` on the key, and `-NaN` / `+NaN` are simply the smallest and largest 
`u64`. No shortcut, and no comparison that silently ignores a value.
   
   ## Cost
   
   Ryzen 9 9950X, 64 MiB L3. `SortKeySketch` column is the shipped path.
   
   | Rows | Column | T-Digest | SortKeySketch | Ratio |
   |---|---|---|---|---|
   | 100K | `Float64` | 1.38 ms (72 M/s) | 1.23 ms (81 M/s) | 0.89x |
   | 100K | `Float64`, 10% NULL | 1.38 ms (72 M/s) | 1.27 ms (78 M/s) | 0.92x |
   | 100K | `Int64` | unsupported | 1.22 ms (82 M/s) | 0.88x |
   | 100K | `Timestamp(ns, UTC)` | unsupported | 1.21 ms (83 M/s) | 0.87x |
   | 1M | `Float64` | 13.8 ms (73 M/s) | 16.4 ms (61 M/s) | 1.19x |
   | 1M | `Float64`, 10% NULL | 13.8 ms (73 M/s) | 15.5 ms (65 M/s) | 1.13x |
   | 1M | `Int64` | unsupported | 16.5 ms (61 M/s) | 1.20x |
   | 1M | `Timestamp(ns, UTC)` | unsupported | 16.4 ms (61 M/s) | 1.20x |
   
   T-Digest is `Float64`-only, so the integer and temporal rows have no 
baseline of their own; those ratios are against T-Digest sketching a float.
   
   Three things to read off it:
   
   - **Column type does not move the cost.** 1.19x, 1.20x and 1.20x for float, 
integer and timestamp. Compaction dominates ingest and the sketch only ever 
sees `u64`, so the encode difference is noise. One number covers every 
fixed-width column.
   - **NULLs are a discount.** 1.13x against 1.19x, because a tenth of the rows 
are counted rather than sketched.
   - **The sign flips with scale.** 11% to 13% faster than T-Digest at 100K, 
13% to 20% behind at 1M, as KLL's per-row cost grows with its compaction stack.
   
   `KLL_K` is chosen for worst-case rank-error parity with T-Digest at 
`max_size=100` (0.0016 against 0.0021 on a uniform 1M stream), so this is a 
matched race rather than a cheaper sketch looking fast. Rerun the parity check 
with `KLL_PARITY_CHECK=1 cargo bench --bench quantile_sketch`.
   
   ## Why not widen T-Digest instead
   
   Worth stating plainly, because for the numeric and temporal types it is a 
real option. You can wrap the routing expression in a `CastExpr` to `Float64` 
and keep T-Digest. The precision loss is genuine but small enough not to 
matter: an `f64` at 2020s epoch nanoseconds quantizes onto a 256 ns grid, while 
T-Digest's own rank error over a day-wide partition is around three minutes. 
Nine orders of magnitude apart. Any argument resting on quantile precision is 
noise, and the sort_key docs say so explicitly so nobody over-claims it later.
   
   What is left after discounting that:
   
   - NULLs still have nowhere to go, and that is not a precision question.
   - The extremes behaviour above is independent of column type.
   - Casting is not free either. It needs a `CastExpr` planted at plan time in 
the AQE rule, and then `RangeFilterExec`'s halos have to be expressed in cast 
units rather than the column's own. That is plan surgery to avoid a code path 
that would otherwise already exist.
   - Float precision is relative, so the cast does degrade for a narrow spread 
at a large magnitude. A partition covering a day is unaffected; one covering 
100 µs at epoch-nanosecond magnitudes is past the point where the cast costs 
more than the sketch does. A corner, but a real one.
   
   Exactness does pay in one place that is not statistical: `min` and `max` are 
exact by construction, tracked outside the compactor so no coin flip can move 
them, and they are the two values `cut_partitions` routes files on. There the 
error bars are zero, so anything a cast rounds away is error introduced where 
none existed.
   
   ## Correctness of the arrangement
   
   Arrow's row format is treated as the definition of `ORDER BY` order 
throughout, since it is the only encoding that folds column type, `nulls_first` 
and `descending` into one memcmp order, and arrow's own sort agrees with it. 
The float transform here is the same one `arrow_row::fixed` applies, and both 
reduce to `total_cmp`, which is what `ArrowNativeTypeOp::compare` uses.
   
   `integer_keys_order_identically_to_arrow_row` asserts the two induce the 
same permutation over a fixture containing ±NaN, ±0.0 and both infinities, so a 
divergence fails a test rather than surfacing as misrouted rows.
   
   Following that order is also why there is no NaN handling anywhere in this 
PR. NaN has a defined place in `total_cmp`, beyond the infinity of its own 
sign, so it encodes to an ordinary key at one end of the `u64` range and 
comparisons against it behave normally.
   
   ## Testing
   
   25 unit tests in `sort_key`, 21 in `kll`, full `ballista-core` suite green.
   
   The NULL remap is validated differentially against `kll_norm_u128`, the 
design this PR prices and rejects. Putting NULLs inside the key needs no remap 
at all, which makes it an independent oracle for the arithmetic. The sweep 
covers NULL fractions from 0 to 100 of 100 rows, both placements, ASC and DESC, 
21 quantiles each.
   
   That oracle earned its keep immediately: it found an off-by-one that eleven 
hand-written tests had missed. Rescaling a rank into a fraction of the value 
run and letting the sketch multiply it back loses it, since with 99 values rank 
59 becomes `59/99` and `59/99 * 99` is `58.999...`, truncating to 58. Fixed by 
keeping the subtraction in integers, which is what `KllSketch::at_rank` exists 
for.
   
   The worked bit-pattern table in `impl_sortable_float`'s docs is asserted by 
`float_key_table_in_docs_is_accurate`, so the hex cannot rot.
   
   ## What this does not do
   
   - Does not touch `RuntimeStatsExec`, T-Digest, or any routing code.
   - `SortKeySketch` has no wire format yet. That lands with its first 
consumer, so the encoding is designed alongside the reader rather than guessed 
at.
   - The arrow-row tier for multi-column and variable-width keys is measured 
here but not built.
   
   ## Follow-ups
   
   1. `RuntimeStatsExec` holds a `SortKeySketch` per partition, plus the proto 
message.
   2. Cuts become sort keys rather than floats through the router, which is 
what lifts the `Float64` gate in `ParallelWindowRule` and lets a `Timestamp` 
`ORDER BY` plan at all.
   3. Multi-column and `Utf8` keys via the arrow-row tier.
   


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