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

   ## Purely additive
   
   Nothing in the tree reads the new sketch. `RuntimeStatsExec` grows a second 
sketch beside its `TDigest`; the routers, the wire format and the 
scheduler-side merge all still read the `TDigest`, and its construction gate is 
untouched. Deleting this diff restores the previous behaviour exactly.
   
   Two details are not literally additive, both inside code that has no other 
caller:
   
   - `SortKeySketch::ingest` now calls `KllSketch::absorb_sorted_slice` rather 
than `absorb_slice`. `SortKeySketch` landed in #2294 and has no in-tree caller 
other than the one this PR adds.
   - `sketch_batches` moves from inside the T-Digest block to the shared ingest 
path, so its guard changes from "at least one non-null value" to "at least one 
row". Identical while the construction gate rejects nullable routing 
expressions, which is today.
   
   ## What this does
   
   `RuntimeStatsExec` builds a `SortKeyCodec` from the first `ORDER BY` 
expression and keeps a per-partition `SortKeySketch` next to each 
`Mutex<TDigest>`. One evaluation of the routing expression feeds both, so the 
second sketch costs a second ingest and not a second `evaluate`. Accessors are 
`sort_key_sketch(partition)` and `merged_sort_key_sketch()`, mirroring the 
T-Digest pair, and `Drop` logs count / nulls / min / max at `debug!` beside the 
existing line.
   
   A `sort_key_sketch_time` metric sits beside `sketch_time` over the same 
batches, so any workload that runs the operator prices the replacement against 
the incumbent without a separate benchmark.
   
   ## Why
   
   `TDigest` is `Float64`-only, single-column, and has no representation for a 
NULL. Those three limits are exactly what `RuntimeStatsExec`'s construction 
gate enforces today, and they are why range repartitioning cannot route on an 
`Int64`, a `Timestamp`, or a nullable column. `SortKeySketch` sketches any 
fixed-width key and positions NULLs per `SortOptions::nulls_first`, so adopting 
it is what lifts the gate.
   
   Running both at once is the point of this step rather than a cost of it: the 
two sketches see the same stream, so the replacement can be held against the 
incumbent on real workloads before anything depends on it. On the #2294 thread 
@phillipleblanc confirmed there is no need to preserve T-Digest compatibility 
once KLL is ready, so the end state is a swap rather than two sketches.
   
   ## Evidence
   
   `cargo bench --bench quantile_sketch`, release, n=1M, Ryzen 9 9950X:
   
   | arm | time | vs T-Digest |
   |---|---:|---:|
   | `tdigest` | 13.737 ms | 1.00x |
   | `sort_key_sketch_f64` | 15.933 ms | 1.16x |
   | `sort_key_sketch_f64_nulls` | 15.236 ms | 1.11x |
   | `sort_key_sketch_i64` | 15.980 ms | 1.16x |
   | `sort_key_sketch_timestamp_ns` | 15.944 ms | 1.16x |
   | `kll_norm_u64` | 17.092 ms | 1.24x |
   
   And the sorted-input pair, which is why `ingest` takes the sorted path:
   
   | arm | time |
   |---|---:|
   | `tdigest_sorted` | 2.187 ms |
   | `kll_ordered_float_absorb_slice` | 25.229 ms |
   | `kll_ordered_float_absorb_sorted_slice` | 7.252 ms |
   
   Sorted input is worth 3.5x to KLL. It is worth 6.3x to T-Digest, which skips 
its own sort and appends centroids, so KLL is relatively more expensive on a 
sorted stream than on an unsorted one. Both sorted arms use the `OrderedFloat` 
representation; there is no sorted arm for the shipped `u64` path yet, so its 
sorted cost comes from the end-to-end run below rather than from this table.
   
   ### End to end
   
   h2o window Q8 at 1e7 on a local 2-executor cluster, 4 vcores each, 8 
partitions, `max_partitions_per_task=4`, release, 
`ballista.planner.parallel_window.enabled=true` (not the default), 3 
iterations. This is the plan shape that puts two `RuntimeStatsExec` taps in 
stage 0:
   
   ```
   ShuffleWriterExec: partitioning: UnknownPartitioning(8)
     RuntimeStatsExec: rows + sketch(routing=v2@3 asc)
       OrderedRangeRepartitionExec: routing=v2@3 asc -> 8 sorted partitions
         SortExec: expr=[v2@3 ASC NULLS LAST], preserve_partitioning=[true]
           RuntimeStatsExec: rows + sketch(routing=v2@3 asc)
             DataSourceExec: file_type=parquet
   ```
   
   Correctness, from the `debug!` lines of both sketches across 62 tap 
instances covering 48.95M row observations: every count equal, **zero min/max 
divergence**, NULLs counted 0 (the gate guarantees that today).
   
   Cost, from the two metrics on the same taps:
   
   | tap | T-Digest | sort-key | ratio |
   |---|---:|---:|---:|
   | below `SortExec`, raw parquet order | 467.2 ms | 537.4 ms | 1.15x |
   | above `ORRE`, sorted | 84.4 ms | 188.9 ms | 2.24x |
   | total | 551.6 ms | 726.2 ms | 1.32x |
   
   +175 ms summed across 12 tap instances of a 2.5s query. The unsorted tap 
reproduces the bench's 1.16x. The sorted tap at 2.24x beats the `OrderedFloat` 
sorted arm's 3.32x, which is the first measurement of the shipped path on 
sorted input.
   
   ## Deliberately not here
   
   - **Consumers.** `discover_cuts`, `cut_partitions`, `merge_reports` and the 
routers still read the T-Digest. Moving them means promoting cuts from 
`Vec<f64>` to `Vec<ScalarValue>`, which pulls in NULL-valued cuts: a typed NULL 
boundary makes every consumer's comparison NULL and drops that partition's rows 
silently. That needs a cut type carrying the NULL run out of band, and it is 
the next PR rather than this one.
   - **The wire format.** `QuantileSketchState` is a 6-element T-Digest 
`ScalarValue` array. A KLL at `KLL_K = 800` retains ~2400 `u64`, so a naive 
`repeated uint64` is roughly 8x the bytes per partition entry. Worth a 
report-level pre-merge (one merged sketch per report, `(min, max, count)` per 
partition) rather than a straight substitution, since no consumer reads a 
per-partition distribution.
   - **Lifting the gate.** It has to stay while the T-Digest ingest downcasts 
to `Float64Array`.
   - **Plan-time sortedness.** `absorb_sorted_slice` decides per batch by 
probing `is_sorted()` and silently falling back. `RuntimeStatsExec` could 
decide once from `input.output_ordering()` and pick an explicit 
`ingest_sorted`, the way `RangeFilterExec` already derives `sorted_on_key`. 
That also makes the choice visible in a plan dump, which it is not today.
   
   ## Test plan
   
   - `cargo test -p ballista-core --lib`: 317 passed.
   - New `sort_key_sketch_agrees_with_tdigest`: 5,000 ascending rows, past 
KLL's k=800 level-0 capacity so compaction runs. Extremes asserted equal 
exactly; interior quantiles within 1% of the range, against the ~0.4% combined 
rank error the two sketches are sized for.
   - `execute_populates_sketch_and_row_count` and 
`execute_row_count_only_no_sketch` extended to cover the new accessors, 
including that row-count-only mode allocates neither sketch.
   - clippy `--no-deps --all-targets -D warnings`, `cargo fmt --all`, 
`RUSTDOCFLAGS='-D warnings' cargo doc --no-deps`: all clean.
   


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