Repository navigation
perf: kll_sketch allocates its items on first use - #558
rustyconover wants to merge 3 commits into
Conversation
proost
left a comment
There was a problem hiding this comment.
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.
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.
84a5c37 to
cc006f0
Compare
Review feedback: rather than each caller guarding against items_ being
nullptr, keep that handling inside the helpers that own item storage:
- allocate_items(): the only place items_ is allocated (update, merge)
- copy_items(): the only place items are copied (copy and converting
constructors), replacing two duplicated null checks
- item_ptr(): the only place an item index becomes a pointer for readers;
without item storage every range is empty, so sort_level_zero(),
check_sorting() and get_sorted_view() need no guards of their own
get_sorted_view() is back to its original form, without the is_empty()
early return. UBSan flags the unguarded pointer arithmetic on an empty
sketch ("applying non-zero offset to null pointer"), and passes with
item_ptr().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@proost thanks for the review, agreed on both points. I've pushed 1fcf432, which moves all handling of an unallocated
That also lets me drop the To confirm the guard is load-bearing: if I put the raw The CI workflows on this PR are still waiting for maintainer approval, so they haven't run yet. |
|
Thanks, Rusty. The DuckDB numbers make a strong case, and the reworked storage helpers from proost's review are a clean way to handle an unallocated Because the same choice affects several sketches (KLL, t-digest and REQ here, and the same sketches in Java), we'd like to agree on one convention across the project before merging any of them. I've posted a proposal on dev@ as a lazy consensus ending Nov 7, 01:00 UTC: https://lists.apache.org/thread/rv730qo61qqkd99dcy4wnglf3hz1s0fz In short, it proposes allocating item storage on first use by default, with an explicit I'm marking this PR as a draft until that resolves. If it passes, we'd like to build on this PR, possibly with a |
kll_sketch's constructor allocates storage forkitems straight away. For the defaultk = 200withdoubleitems, 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 firstupdate()or non-emptymerge()instead:internal_update(), which bothupdate()and the level-zero part ofmerge()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.const_iteratoralready handleitems_ == 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_allocatorto check:The new custom-type empty-view test reproduces
applying non-zero offset 1600 to null pointerunder UBSan before the guard is added.Validation:
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) andtdigest(centroids_andbuffer_) allocate eagerly too; if this approach is welcome, I can send the same change for them.🤖 Generated with Claude Code