Skip to content

perf: kll_sketch allocates its items on first use - #558

Open
rustyconover wants to merge 2 commits into
apache:masterfrom
rustyconover:feat/kll-lazy-items
Open

rustyconover wants to merge 2 commits into
apache:masterfrom
rustyconover:feat/kll-lazy-items

Conversation

@rustyconover

@rustyconover rustyconover commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

kll_sketch's constructor allocates storage for k items straight away. For the default k = 200 with double items, that's 1.6 KB per sketch before anything has been added.

That's costly when an application creates many sketches that stay empty or small, for example one per group in a grouped aggregation. Query engines also create short-lived partial states that are only ever merged into, or that see a single row each before being combined.

This PR allocates items_ on the first update() or non-empty merge() instead:

  • internal_update(), which both update() and the level-zero part of merge() go through, allocates on demand.
  • merge() also allocates before merging the higher levels, because the other sketch's level zero can be empty while its higher levels aren't.
  • The copy and converting constructors allocate only if the source has items.
  • The destructor and const_iterator already handle items_ == nullptr.
  • get_sorted_view() returns an empty view before sorting or constructing item ranges when the sketch is empty. This single check protects both paths without allocating item storage; sort_level_zero() is private and is called only from this guarded entry point.

Results and the serialized format are unchanged.

Tests

Regression coverage uses test_allocator to check:

  • Empty sketches, their copies, and empty merges allocate only the levels; the first update allocates item storage, and destruction releases it.
  • Copy and move merges into sketches without item storage preserve counts and extrema through estimation mode.
  • Empty sorted views are safe and allocate nothing after construction, copying, type conversion, moving, assignment over an existing cached view, and both serialization round trips. First updates after those empty views still allocate and sort correctly.
  • Numeric sketches and sketches receiving only NaN updates also return empty sorted views safely.

The new custom-type empty-view test reproduces applying non-zero offset 1600 to null pointer under UBSan before the guard is added.

Validation:

  • All 17 CTest suites pass on Linux aarch64 with GCC 14.
  • KLL, integration, and deserialization-hardening suites pass with GCC AddressSanitizer, UndefinedBehaviorSanitizer, and libstdc++ assertions enabled.
  • The full KLL suite passes on macOS with Apple Clang 17, libc++, and UBSan.

Context

We found this in the DuckDB DataSketches extension. For GROUP BY queries with many groups, DuckDB creates a separate aggregate state for each input row and merges them later, and the eager allocation made a KLL aggregate over 2M groups use tens of GB. DuckDB made the same change to its own t-digest for the same reason. req_sketch (its first compactor) and tdigest (centroids_ and buffer_) allocate eagerly too; if this approach is welcome, I can send the same change for them.

🤖 Generated with Claude Code

@proost proost left a comment

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.

Idea is good, But null checking isn't enough.

For example, sort_level_zero methods should check null for items_.

And we should guard all null cases in one place not each call sites.

rustyconover and others added 2 commits October 29, 2026 12:13
The constructor allocated storage for k items up front, so every empty
sketch held k * sizeof(T) bytes (1.6 KB for the default k with doubles). Code
that creates many sketches that stay empty or small (one per group in a
grouped aggregation) paid for all of it.

Allocate the items on the first update or non-empty merge instead. The copy
and converting constructors only allocate if the source has items; the
destructor and the iterator already handle an unallocated buffer. Results and
the serialized format are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check for an empty sketch in get_sorted_view before sorting level zero or forming item ranges. This shared entry point is the only caller of sort_level_zero, so no per-call-site null checks or eager allocation are needed.

Exercise empty views after construction, copies, moves, assignments and deserialization, then verify that first updates still allocate and sort correctly. The new custom-type regression reproduces the null-pointer offset under UBSan before the fix.

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.

2 participants