Add opt-in off-heap group-by key tables and result holders for SSE - #19380
Open
xiangfu0 wants to merge 2 commits into
Open
Add opt-in off-heap group-by key tables and result holders for SSE#19380xiangfu0 wants to merge 2 commits into
xiangfu0 wants to merge 2 commits into
Conversation
xiangfu0
force-pushed
the
xiangfu0/offheap-groupby-sse
branch
2 times, most recently
from
August 28, 2026 09:25
68a5251 to
935299a
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #19380 +/- ##
=============================================
+ Coverage 57.71% 67.72% +10.01%
- Complexity 7 1430 +1423
=============================================
Files 2686 3495 +809
Lines 163987 225342 +61355
Branches 26627 35571 +8944
=============================================
+ Hits 94640 152618 +57978
+ Misses 61352 60665 -687
- Partials 7995 12059 +4064
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…nerator group counts For primitive stored types with null handling enabled, the null group lives outside the primitive key map but still takes the next dense group id, so getNumKeys() and getCurrentGroupKeyUpperBound() under-counted by one once a null was seen. Since DefaultGroupByExecutor sizes result holders with ensureCapacity(getCurrentGroupKeyUpperBound()), a segment whose group count exceeds the initial holder capacity then wrote one slot past the holder array (ArrayIndexOutOfBoundsException) on the default on-heap path. Object stored types are unaffected (their null key lives inside the map). Adds NoDictionaryNullGroupCountRegressionTest reproducing the AIOOBE through DefaultGroupByExecutor.process() with a shrunk maxInitialResultHolderCapacity for all four primitive stored types, and pinning the counts and the null-group emission.
…g-gated, default off)
Adds an opt-in off-heap storage mode for the SSE per-segment group-by state, targeting
high-cardinality group-bys whose on-heap key maps and result holders drive GC pressure:
- New pinot-core package o.a.p.core.query.aggregation.groupby.offheap:
- OffHeapIntGroupIdMap / OffHeapLongGroupIdMap: open-addressing key->dense-id tables over direct
memory (8/16-byte slots, load factor 0.5, linear probing, out-of-band -1/0 key), drop-in
replacements for IntGroupIdMap / Long2IntOpenHashMap semantics.
- OffHeapBytesGroupIdMap: DuckDB-style two-part table for var-width keys — an 8-byte-entry
directory (16-bit salt | 48-bit payload offset) over append-only 256KB payload chunks storing
[hash][groupId][keyLength][key bytes]; the stored hash makes directory resize free of key reads.
- OffHeapDouble/Long/IntGroupByResultHolder: fixed-width result holders over direct memory with
semantics identical to the on-heap holders.
- ResourceTrackingGroupKeyGenerator: wraps the generator and owns every off-heap resource, so the
existing generator close() call sites release all direct memory (including the shared-generator
filtered-aggregation case).
- OffHeapGroupByBufferPool: bounded per-thread buffer reuse across queries (mirrors the on-heap
thread-local map caching, with an explicit cap and visible accounting), default off.
- All hot paths use absolute-indexed direct ByteBuffer views (wrapper fallback beyond 2GB).
- Wiring: server config pinot.server.query.executor.groupby.offheap (default false), query option
groupByOffHeap, pool cap config groupby.offheap.pool.max.bytes.per.thread (default 0). Off-heap
RawKeyHolder variants in DictionaryBasedGroupKeyGenerator (the ARRAY_BASED tier stays on-heap);
off-heap modes in both NoDictionary generators; holder mirroring in DefaultGroupByExecutor.
Grouping sets stay on-heap. Group ids remain dense ints; no AggregationFunction changes; no wire
or storage format changes.
- Close-path hardening (also fixes pre-existing on-heap leak windows): exception guards in
GroupByOperator/FilteredGroupByOperator/DefaultGroupByExecutor and widened finally coverage in
the group-by combine operator. The streaming combine needs no extra plumbing: since apache#19066 each
per-segment result is detached and its generator closed on the producing worker thread, which
releases the off-heap state promptly as well.
- Tests: differential suites comparing off-heap vs on-heap row-for-row (OffHeapGroupByQueriesTest
end-to-end battery with per-query direct-memory leak assertions, OffHeapGroupKeyGeneratorParityTest
at generator level incl. null-group id bookkeeping), per-structure unit tests incl. forced
wrapper-fallback runs, and buffer pool tests.
- Benchmarks (pinot-perf): BenchmarkOffHeapGroupBySSE / -LargeSSE / -HugeSSE and
OffHeapGroupByMemoryFootprint. Measured: retained heap for the per-segment state drops to ~0
(e.g. 8.7GB -> 4MB at 100M string groups, with 3.7x faster build); at ~1M+ groups off-heap is
faster end-to-end (up to -50%) because it removes the GC pressure that dominates on-heap; at
~80K groups (cache-resident) there is a 10-22% latency premium, which the per-query opt-in
avoids.
xiangfu0
force-pushed
the
xiangfu0/offheap-groupby-sse
branch
from
August 29, 2026 09:07
935299a to
137b6c0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an opt-in off-heap storage mode for the SSE per-segment group-by state — the group-key
tables and the fixed-width aggregation result holders — targeting high-cardinality group-bys where
the on-heap maps/arrays drive GC pressure and heap-retention spikes. Default off; no behavior
change unless enabled.
pinot.server.query.executor.groupby.offheap(defaultfalse)SET groupByOffHeap = true;(overrides the server config per query, same precedentas
numGroupsLimit)pinot.server.query.executor.groupby.offheap.pool.max.bytes.per.thread(default
0= off)Group ids remain dense ints in insertion order, so no
AggregationFunctionchanges are needed,and there are no wire or storage format changes. Grouping sets stay on-heap.
Design
New
pinot-corepackageorg.apache.pinot.core.query.aggregation.groupby.offheap, backed byPinotDataBuffer.allocateDirect(so the bytes show up in the existing direct-buffer accounting),with absolute-indexed direct
ByteBufferviews on all hot paths (transparent wrapper fallback forstructures beyond the 2GB view limit — exercised in tests and at 100M groups):
OffHeapIntGroupIdMapIntGroupIdMap(dict tier), raw INT/FLOAT fastutil maps[key+1][groupId], LF 0.5, linear probing, out-of-band-1keyOffHeapLongGroupIdMapLong2IntOpenHashMap(dict long tier, raw LONG/DOUBLE, packed two-int keys)OffHeapBytesGroupIdMapObject2IntOpenHashMap<String/…>(raw STRING/BYTES/BIG_DECIMAL, multi-column packed keys)[hash][groupId][keyLen][key bytes]; the stored hash makes directory resize free of key readsOffHeapDouble/Long/IntGroupByResultHolderensureCapacitygrowth)Doubles/floats are keyed via
doubleToLongBits/floatToIntBitsfor exact fastutil parity (NaNcollapse, ±0.0 distinct). Strings are encoded with an inline UTF-8 encoder byte-identical to
String.getBytes(UTF_8)(including surrogate handling), so keys hash/compare identically to theon-heap path.
Lifecycle: a
ResourceTrackingGroupKeyGeneratorwraps the generator and owns every off-heapresource, so the existing generator-
close()call sites release all direct memory — including theFilteredGroupByOperatorshared-generator case. This PR also hardens close paths that previouslyleaked on exceptions (guards in
GroupByOperator/FilteredGroupByOperator/DefaultGroupByExecutorand widened
finallycoverage in the group-by combine operator). The streaming combine needs noextra plumbing: since #19066 each per-segment result is detached and its generator closed on the
producing worker thread, which releases the off-heap state promptly as well.
Buffer pool:
OffHeapGroupByBufferPooloptionally caches freed buffers per thread with exact-sizefree lists under a byte cap, mirroring the on-heap thread-local map reuse across queries; pooled bytes
stay visible in direct-buffer usage, and structures zero-fill on acquire so dirty reuse is safe.
Benchmarks
New in
pinot-perf:BenchmarkOffHeapGroupBySSE(5 tiers × flag),BenchmarkOffHeapGroupByLargeSSE(1M groups),
BenchmarkOffHeapGroupByHugeSSE(10M groups, segment phase),BenchmarkOffHeapGroupIdMaps(micro), and
OffHeapGroupByMemoryFootprint(deterministic retained-heap/direct harness, up to 100Mgroups). All numbers below were re-measured on this rebased branch (base
5e914c979b), sequentially onan otherwise idle M-series Mac (32GB), JDK 25, fixed heaps,
-prof gc; JMH rows are avgt ms/op ± 99.9% CI.~80K groups per query (cache-resident, the off-heap worst case) — full-query latency, 4 segments ×
300K rows, 10×5s measurement iterations:
The dict/raw-int premium is why the feature is a per-query/per-server opt-in. (It reads wider than the
pre-rebase measurement mainly because the on-heap baseline itself got faster on current master; the
off-heap side is unchanged.)
1M groups (the trade inverts at scale) — 2 segments × 2M rows:
segment phase
DICT_INT−18% (54.8 vs 67.1ms, alloc 65MB→0.3MB/op, GC 32→0ms); full queryDICT_INT−26% (253 vs 341ms, alloc 380→251MB/op);
RAW_STRINGlatency parity with in-measurement GC time3014→23ms (segment phase 4284→14ms — the on-heap arm spends seconds per iteration in GC that simply
disappears).
10M groups (segment phase, 1×12M-row segment) —
DICT_INT−33% (624 vs 933ms, alloc519MB→0.5MB/op, GC 101→0ms);
RAW_STRINGparity (+3.8%, within error) with GC time 3696→26ms andalloc 1761→1193MB/op.
Retained memory for the per-segment state (map + 2 holders, GC-forced):
(The int-100M direct total is larger than on-heap at that exact count — LF 0.5 vs 0.75 on a pow2
boundary; the point of the mode is where the bytes live, not always fewer of them.)
Included fix (first commit, affects the default on-heap path)
NoDictionarySingleColumnGroupKeyGeneratorkeeps the null group outside its key map forprimitive types but did not count it in
getNumKeys()/getCurrentGroupKeyUpperBound(). Since thenull group takes the next dense id, the upper bound could equal an issued id — a latent
result-holder under-sizing (AIOOBE) and wrong
numKeysbookkeeping wherever those counts are used(e.g.
TableResizertrim decisions). Split into its own commit so it can be evaluated independently,with
NoDictionaryNullGroupCountRegressionTestreproducing the AIOOBE throughDefaultGroupByExecutor.process()for all four primitive stored types (verified to fail withArrayIndexOutOfBoundsExceptionwhen the fix is reverted).Testing
OffHeapGroupByQueriesTest— end-to-end differential battery running every query with the flag onand off and comparing results row-for-row, with per-query direct-memory leak assertions.
OffHeapGroupKeyGeneratorParityTest— generator-level parity incl. null-group id bookkeeping.vs the JDK, incl. surrogates), plus forced wrapper-fallback (>2GB view) runs for every structure.
StreamingGroupByCombineOperatorTest): with off-heap enabled andhigh-cardinality segments, direct-buffer usage returns exactly to baseline both after full
consumption and when the consumer abandons the stream after the first block.
tests green; spotless/checkstyle/license clean on
pinot-spi,pinot-common,pinot-core,pinot-perf.Known limitations
numGroupsLimit(see the config javadoc). Size-XX:MaxDirectMemorySizebefore enabling.asserton the hot path (tests run withassertions enabled; production does not), trading the on-heap path's implicit AIOOBE for speed.
off-heap there only relieves the segment-execution phase itself.
Follow-ups (out of scope here)
IndexedTablefor the cross-segment combine (dominates at 10M+ groups; the currentcombine is unchanged and mode-independent).
pooled bytes.
🤖 Generated with Claude Code