Skip to content

perf: kll_sketch grows its items buffer by 8x up to k - #569

Open
stojkomilos wants to merge 12 commits into
apache:masterfrom
stojkomilos:stojkomilos/kll-lazy-growth
Open

stojkomilos wants to merge 12 commits into
apache:masterfrom
stojkomilos:stojkomilos/kll-lazy-growth

Conversation

@stojkomilos

@stojkomilos stojkomilos commented Nov 7, 2026 •

Copy link
Copy Markdown

What this adds on top of #558

On master, a sketch allocates its k items in the constructor, and #558 moves that allocation to the first update or merge. Either way a sketch with one item holds 512 KiB at k = 65535 with doubles. This PR makes that allocation room for min(k, 200) items (200 is DEFAULT_K), and grows a full buffer by 8x up to k: 200, 1600, 12800, then k.

  • Compaction still starts only when the buffer holds k items, so the compaction points and random bits do not change.

  • Once the buffer holds k items, both serializers run the same code as master. While it is still growing, with one level, they write the start of level 0 as an offset into a buffer of k items, so the serialized bytes and to_string() do not change. A deserialized sketch still gets the full buffer of k items.

  • After the first compaction nothing changes.

Bytes held by a kll_sketch<double> with k = 65535 (items buffer and levels), measured with a counting allocator on master (c22f2b1), this PR (4b036fc), #558 (1fcf432) and #558 + this PR:

Items master this PR #558 #558 + this PR
0 524,288 1,608 8 8
1 to 200 524,288 1,608 524,288 1,608
201 to 1600 524,288 12,808 524,288 12,808
1601 to 12800 524,288 102,408 524,288 102,408
more 524,288 524,288 524,288 524,288

At the default k = 200 nothing changes, because the first allocation is already k items.

Tests

Added

kll sketch: lazy growth of the items buffer (7 sections). Every section also requires that all test_allocator bytes are freed at the end.

  • Start size (1 section): with k = DEFAULT_K / 2, the test_allocator bytes stay at k items, not DEFAULT_K items.
  • Growth by 8x (1 section): at k = 65535, the bytes follow 200, 1600, 12800 and 65535 items, and compaction starts only after 65535 items.
  • Merge growth (1 section): at k = 65535, merging 1000 items into an empty sketch grows its buffer to 1600 items, as updates do.

The next 4 bullets run a growing sketch next to a copy with a full buffer (a deserialized sketch), with the same random_utils::random_bit state. They require the same items, weights, min, max, quantiles, ranks, PMF, CDF, both serializations and to_string().

  • Updates (3 k = 3 runs): k = 201, 5000 and 20000, up to 10 times k items, compared at each growth step and around compactions.
  • Merges (6 inputs x 2 orders = 12 merges): compared after each merge and after 50000 more updates.
  • Copies (6 sketches): the original, a copy, a copy assignment, a move, a move assignment and a float to double conversion, compared after 1000, 20000 and 200000 more updates.
  • Strings (1 run): k = 5000, compared around each growth step and at 10001 and 30000 items.

Test strength: without the k_ - items_size_ shift in either serializer, 4 checks fail. Without the min in the start size, 1 check fails.

All 17 CTest suites pass with GCC 10.5 and C++11, and kll_test also passes with ASan and UBSan. A separate harness (not in this PR) wrote bitwise-identical serializations with master, with this PR, and with this PR on top of #558. It covers double, float, int64 and string items, 15 values of k, n around every buffer size, 4 input orders, 2 seeds, merges by copy and by move, and the type-converting constructor. With RESIZE_FACTOR set to X1, X2 or X4, it also wrote the same bytes as master.

Benchmarks

A Google Benchmark program on kll_sketch<double>, with items from a fixed-seed 64-bit LCG (integers spread uniformly over [0, 2^53)). I built it against master (c22f2b1) and this PR (b4fcf81), with GCC 10.5 in Release, on an x86_64 VM (Xeon 8375C). The binaries ran in 7 rounds in random order on one pinned core, with 5 repetitions per round. The VM is shared, so the times are the fastest of the 35 runs. The ratios of the median times are within 6% of these.

The last column is master / this PR: 5 means this PR is 5 times faster, and 0.2 means 5 times slower.

Case k master this PR master / this PR
1000 live sketches, 10 items each 5000, 20000, 65535 1199, 1538, 1684 us 74, 74, 74 us 16.2, 20.8, 22.8
One sketch, 10^6 items 800 35.6 ms 36.1 ms 0.99
One sketch, 10^7 items 65535 650 ms 654 ms 0.99

This pull request and its description were written by Isaac.

stojkomilos and others added 2 commits November 9, 2026 09:29
A new sketch has room for min(k, 200) items instead of k, and a full
buffer smaller than k grows by RESIZE_FACTOR (X8) up to k. Compaction
still starts only when the buffer holds k items, and while there is one
level the serialized levels are offsets into a buffer of k items, so
results and serialized bytes do not change.

Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
@stojkomilos
stojkomilos force-pushed the stojkomilos/kll-lazy-growth branch from 2c4aad4 to 74b60d1 Compare November 9, 2026 09:44
stojkomilos and others added 6 commits November 9, 2026 10:40
merge() reads the items of the other sketch while this sketch moves
them, so merging a sketch into itself failed once the buffer grew or
compacted. to_string() again prints k as the capacity of a one-level
sketch, and the start size and growth factor are private constants.

Co-authored-by: Isaac <no-reply@databricks.com>
With X1, grow_items() kept the buffer size, and the next update wrote
before the start of the buffer. Like theta, the sketch now starts with
the full buffer if the resize factor is X1. The start size is
DEFAULT_K, the buffer of a sketch with the default k, so the
INITIAL_ITEMS_SIZE constant is removed.

Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
merge() reads the items of the other sketch while it updates this one,
so a self-merge failed once it compacted, and the growing buffer made
it fail earlier. The Java KLL sketches throw "A sketch cannot merge
with itself." (apache/datasketches-java#516), and so does count_min.
The static_assert on RESIZE_FACTOR now checks only the lower bound.

Co-authored-by: Isaac <no-reply@databricks.com>
Only a one-level sketch whose buffer is still growing writes the start
of level 0 as an offset into a buffer of k items. Otherwise both
serializers run the same code as before.

Co-authored-by: Isaac <no-reply@databricks.com>
Throwing on a self-merge, as Java does, changes behavior, so it is left
for a separate PR. A self-merge reads the items it moves, so it first
grows the buffer to k items. It then runs on the same buffer as on
master, with the same result or the same error.

Co-authored-by: Isaac <no-reply@databricks.com>
stojkomilos and others added 3 commits November 9, 2026 12:26
apache#570 makes a self-merge throw, as Java does, so merge() is the same as
on master here. Without apache#570, a self-merge of a sketch whose buffer is
still growing can fail where master works, so this PR merges after apache#570.

Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
…00 and 65535

Co-authored-by: Isaac <no-reply@databricks.com>
@stojkomilos
stojkomilos marked this pull request as ready for review November 9, 2026 13:01
Co-authored-by: Isaac <no-reply@databricks.com>

This branch has not been deployed

No deployments
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.

1 participant