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 |
|
@rustyconover Nice work. Could you please add geometric increasing of size for the first level/layer of the sketch, just like your other PR #561, and just like theta and tuple do this also already on master. If you don't want to do that in this PR, that is reasonable. I will do it after you merge this one (I already have a drafted PR for this, here #569) Perhaps @leerho can give his 2 cents. |
|
@leerho following up now that the proposal's November 7, 01:00 UTC deadline has passed. I see a supportive reply and no objections in the archived thread. Could you confirm whether the proposal is accepted and when we can take #558 and #561 out of draft? You offered to make the C++ and Java changes starting from these PRs; are you planning to add @stojkomilos thanks for offering to handle geometric growth and putting together #569. I'd like to keep this PR focused on allocation on first use and leave geometric growth to your follow-up in #569 after this merges. Please go ahead with polishing that PR on that basis, subject to the project's allocation convention. @proost could you take another look at |
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:allocate_items()centralizes allocation for updates and merges, including merges where the source has items only in higher levels.copy_items()is shared by the copy and converting constructors and leaves item storage unallocated when the source has none.item_ptr(index)centralizes access to item ranges. It returnsnullptrwhen item storage is unallocated;sort_level_zero(),check_sorting()andget_sorted_view()use it to handle empty ranges without pointer arithmetic on null storage.const_iteratoralready handleitems_ == nullptr.Results and the serialized format are unchanged.
Tests
Regression coverage uses
test_allocatorto check:The custom-type empty-view tests expose null-pointer arithmetic under UBSan if
sort_level_zero()uses rawitems_ + levels_[0]instead ofitem_ptr().Validation:
1fcf432f, including the compiler matrix, sanitizers, libc++ hardening, coverage and license audit.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. The corresponding t-digest change is in #561. REQ also allocates its first compactor eagerly and is covered by the project-wide allocation proposal.
🤖 Generated with Claude Code