Skip to content

perf(kll): speed up updates, merges and queries - #332

Open
Waterpine wants to merge 2 commits into
apache:mainfrom
Waterpine:perf/kll-update-merge-query
Open

Waterpine wants to merge 2 commits into
apache:mainfrom
Waterpine:perf/kll-update-merge-query

Conversation

@Waterpine

@Waterpine Waterpine commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

KllSketch now does less work per update, merge, deserialization, and sorted-view query. Results of every operation are unchanged: same retained items, same tie order, same serialized bytes, same deserialization errors.

On the benchmark machine, updates take 7% to 13% less time, merge 16% less, deserialize 13% less, and quantile and quantiles 14% less (time ratios 0.93 to 0.84, see "Results").

  • Updates: the total capacity depends only on the number of levels, so a compaction recomputes it only when it adds a level.
  • Merges: merge reads the other sketch's levels by reference instead of cloning them first. For item types without drop glue, the sorted merge selects the source with the comparison result instead of branching on it.
  • Memory: when a compaction adds a new top level, that level holds the downsampled half in the compacted level's buffer, about twice its length, and keeps it until the level below compacts again. It is now shrunk to fit, which costs one reallocation per added level.
  • Deserialization: a sorted level lies within its first and last items, so the minimum/maximum check looks at those two and falls back to the full scan only when one of them is out of range. The error names the same first offending item as before.
  • Sorted view: the first merge round combines pairs of levels straight from the sketch instead of copying every level into entries first; later rounds are unchanged.

Results

Main benchmark suite, 8 rounds. The ratio is the time after this change divided by the time before; below 1 is faster.

benchmark time ratio
kll::update::update/1000 0.93
kll::update::update/10000 0.91
kll::update::update/100000 0.87
kll::merge::merge 0.84
kll::serde::deserialize 0.87
kll::query::quantile/0.5 0.86
kll::query::batch_quantiles 0.86

kll::query::rank, quantile/0.0, quantile/1.0, and sorted_view_quantile are unchanged within noise (0.996 to 1.007); they do not run the changed code.

Additional workloads that are not in the repository's suite (8 rounds, k = 200 unless noted):

workload time ratio
update, f32 / i64 items 0.83 / 0.84
update, f64, k = 8 / k = 1000 0.79 / 0.91
update, ascending / descending / many equal values 0.85 / 0.80 / 0.79
merge, f32 / i64 items 0.81 / 0.80
merge 256 sketches of 500 items into one 0.95
deserialize, f32 / i64 items 0.85 / 0.90
deserialize, f64 k = 1000 0.88
quantile, f64 k = 1000 0.94

Memory: I measured the exact heap owned by a sketch with a counting allocator and a fixed random seed, so that this branch and main draw identical compaction bits. It was sampled after each of the first 20,000 updates (and every 97th update up to 300,000) and after each of 256 merges, for k = 8, 50, 200, 1000, with f32 and f64 items and merge parts of 50, 500, and 5,000 items. Of 186,160 samples, none is higher than before; the mean is 4% to 16% lower.

Scope of the results

The gains come from doing less work, not from a CPU feature, but they depend on the workload:

  • Item types with drop glue gain less. With String items, updates take 2% less time and deserialization 11% less; merge and quantile are about the same (ratios 1.00 and 0.97).
  • Small sketches gain nothing: 2,000 updates at k = 200, and deserializing a 50-item sketch, take the same time as before.
  • The select-based merge replaces branches that mispredict on interleaved input; how much that is worth depends on the CPU's branch predictor. When the inputs barely interleave, it can cost a little: merging a 100,000-item sketch into a 100-item sketch takes 2.50 µs instead of 2.45 µs (ratio 1.02), and without the select-based merge it takes 2.47 µs.
  • The extra shrink_to_fit adds one reallocation per added level. Removing it did not change the time of that merge (2.50 µs either way).
  • In a harness that times short bursts of quantile (about 1 to 8 ms) right after building many sketches, this branch was sometimes slower than main (i64, k = 200: 0.96 ms instead of 0.81 ms in 7 of 10 processes), with the same instruction count but more mispredicted branches; main did not vary this way. In longer runs, both in that harness and in a program that builds a single sketch, quantile was consistently faster (time ratios 0.72 to 0.84 for f64 and i64, k = 8 and 200). I don't know the cause.
  • kll::serde::serialize, which this change does not touch, measured 1.01.

All numbers come from one machine; I have not measured others.

Checks

cargo x check, cargo x lint, cargo x test, and cargo +1.86.0 x test pass.

Measured on one machine (2x Xeon E5-2660 v3, Linux 6.8) against main at 4ba130e with the repository's benchmark runner (kll:: --repeat 3), pinned to one core, with the two builds alternating over 8 rounds and the same fixed random seed in both; ratios are the geometric mean of per-round median ratios.

Results are unchanged.

* Recompute the total capacity only when a compaction adds a level.
* Merge from the other sketch's levels by reference, and select the merge source
  with the comparison result for item types without drop glue.
* Shrink the new top level when a compaction adds one; it held the downsampled
  half in the compacted level's buffer.
* Check a sorted level's minimum/maximum bounds at its first and last items and
  scan the whole level only when one is out of range.
* Merge pairs of levels straight from the sketch in the first sorted-view round.
* Compare `KllFloat` with `<` and `>` instead of `partial_cmp().unwrap()`.

Add reference tests for sorted views, merges, long-stream invariants, and the
position reported for an out-of-range item.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But self.0.partial_cmp(&other.0).unwrap() just work well?

We don't forbid unwrap especially you already prove that the branch is unreachable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, makes sense. I've restored partial_cmp().unwrap() (b7e4fcf). For the record, it was there for speed rather than to avoid unwrap(): the compiler keeps the NaN check and panic branch in every comparison, and on this branch </> made quantile/quantiles take about 19% less time. That part of the gain is gone now, and I've updated the numbers in the description.

Restore the comparison from main, as asked in review. The order of
retained items and every result are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants