Skip to content

Speed up exact DISTINCT aggregations on dictionary-encoded LONG columns#19056

Open
arunkumarucet wants to merge 3 commits into
apache:masterfrom
arunkumarucet:arun/distinctcount-sorted-dictionary-runs
Open

Speed up exact DISTINCT aggregations on dictionary-encoded LONG columns#19056
arunkumarucet wants to merge 3 commits into
apache:masterfrom
arunkumarucet:arun/distinctcount-sorted-dictionary-runs

Conversation

@arunkumarucet

Copy link
Copy Markdown
Contributor

Motivation

While benchmarking DISTINCTCOUNT on ClickBench data (10M rows, UserID LONG with ~1.53M distinct values) I profiled the server with async-profiler and found ~80% of CPU in LongOpenHashSet add/rehash/addAll, even though the query runs on the no-scan path where values come straight out of the dictionary — which is already sorted and duplicate-free for immutable segments.

NonScanBasedAggregationOperator.getDistinctValueSet() hashes every dictionary value into a per-segment LongOpenHashSet, and the combine phase then hash-merges the per-segment sets. For high-cardinality LONG columns basically the whole query is spent re-deduplicating values the dictionary already deduplicated.

Change

Added SortedLongDistinctSet (a LongSet backed by sorted runs) and used it in the LONG branch of getDistinctValueSet():

  • Per segment, dictionary values are copied into a sorted run with no hashing. Sortedness is verified during the copy (near-zero cost), so mutable/realtime dictionaries, which are insertion-ordered, fall back to sort+dedupe instead of being trusted blindly.
  • merge() during the combine phase just appends the other set's runs.
  • The actual union happens once, lazily, when the result is first read (count extraction or serialization): a multi-pass merge between two pre-allocated buffers on the query thread, with a query-termination check between passes. No per-merge garbage, no ForkJoin common pool.
  • Pending runs are eagerly compacted (with geometric backoff) once they cross an internal threshold, so peak memory stays proportional to the distinct count rather than to the number of segments when segments carry overlapping values.

Only LONG is changed for now — it's the common high-cardinality case (IDs, timestamps). INT/FLOAT/DOUBLE keep the hash-set path; the same approach applies if profiling justifies it.

The intermediate result still serializes through the existing ObjectType.LongSet layout (size + values), so nothing changes on the wire and mixed-version brokers/servers are unaffected. DISTINCTSUM/DISTINCTAVG and the SMARTHLL/SMARTULL under-threshold paths consume the same set through the Set/LongSet interfaces and are covered by tests.

Numbers

ClickBench hits, 10M rows / 10 segments, single server (M-series laptop), warm best-of-4:

query before after
DISTINCTCOUNT(UserID) (1.53M distinct) 37ms 19ms
COUNT(DISTINCT UserID) 37ms 19ms
DISTINCTCOUNT(RegionID) (3.6K distinct) 1ms 1ms
DISTINCTCOUNT(UserID) group by RegionID 66ms 66ms (unchanged, scan path)
DISTINCTCOUNTHLL / SMARTHLL / HLLPLUS unchanged

Results are identical to the hash-set implementation (verified value-for-value, not just counts).

An isolated microbenchmark of the union itself (10 runs matching the real per-segment cardinalities, ~1.59M values): balanced pairwise merge 2.8ms, k-way heap merge 7.3ms, concat+sort 2.3ms — pairwise multi-pass merging with reused buffers was chosen; the allocation reduction mattered more than the merge strategy (the old per-level allocations produced ~50MB of garbage per query and GC showed up at ~12% of profile samples, ~2% after).

Testing

  • SortedLongDistinctSetTest (15 cases): single/multi runs, overlapping and disjoint merges, mixed merges with LongOpenHashSet in both directions, unsorted input fallback, merge-after-materialization, dedupe-slack handling, eager compaction (3x3M runs with an arithmetically known union of 7M), serde round-trip, and the unsupported-removal contract.
  • NonScanBasedAggregationOperatorTest (4 cases): the sortedness-detection loop against mocked sorted, unsorted, and duplicate-bearing dictionaries (the realtime/mutable case).
  • Existing DistinctCountQueriesTest, DistinctQueriesTest, DistinctSum/AvgAggregationFunctionTest, SegmentPartitionedDistinctCountQueriesTest all pass.
  • Additionally ran a randomized differential fuzz (200 trials, random run counts/sizes/overlaps/ranges) comparing size/iteration/contains/sum against LongOpenHashSet ground truth.

For unfiltered DISTINCTCOUNT/DISTINCTSUM/DISTINCTAVG on a dictionary-encoded
LONG column, the no-scan path was hashing every dictionary value into a
per-segment LongOpenHashSet and hash-merging the sets during combine, even
though immutable dictionaries are already sorted and duplicate-free.
Profiling a high-cardinality DISTINCTCOUNT (10M rows, 1.53M distinct) showed
~80% of CPU in hash set add/rehash/merge.

Keep the dictionary values as sorted runs instead (SortedLongDistinctSet):
segment merge appends runs, and the union happens once at result extraction
as a multi-pass merge between two pre-allocated buffers on the query thread,
with termination checks between passes and eager compaction to bound memory
when many segments overlap. Mutable (realtime) dictionaries are detected via
a per-value sortedness check and fall back to sort+dedupe.

Wire format is unchanged (same LongSet object type and byte layout).
Measured on ClickBench hits (10M rows, 10 segments): DISTINCTCOUNT(UserID)
37ms -> 19ms warm, identical results; group-by and approximate variants
unchanged.
@codecov-commenter

codecov-commenter commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.17647% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.46%. Comparing base (86535a9) to head (975e25c).
⚠️ Report is 10 commits behind head on master.

Files with missing lines Patch % Lines
...query/aggregation/utils/SortedLongDistinctSet.java 92.02% 3 Missing and 10 partials ⚠️
.../DistinctCountSmartHLLPlusAggregationFunction.java 0.00% 1 Missing ⚠️
...tion/DistinctCountSmartULLAggregationFunction.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19056      +/-   ##
============================================
+ Coverage     65.44%   65.46%   +0.02%     
  Complexity     1421     1421              
============================================
  Files          3425     3426       +1     
  Lines        216146   216465     +319     
  Branches      34231    34296      +65     
============================================
+ Hits         141452   141712     +260     
- Misses        63312    63345      +33     
- Partials      11382    11408      +26     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 65.46% <91.17%> (+0.02%) ⬆️
temurin 65.46% <91.17%> (+0.02%) ⬆️
unittests 65.46% <91.17%> (+0.02%) ⬆️
unittests1 56.86% <91.17%> (+0.01%) ⬆️
unittests2 37.82% <0.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gortiz
gortiz requested review from gortiz and yashmayya July 23, 2026 07:25
@xiangfu0

Copy link
Copy Markdown
Contributor

I recently found a lot of similar perf optimization by rewrite the data structure wrapper, shall we consider rewrite a new wrapper class similar to fastutils? cc: @gortiz

@Jackie-Jiang Jackie-Jiang added enhancement Improvement to existing functionality query Related to query processing labels Jul 23, 2026

@Jackie-Jiang Jackie-Jiang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can backfire when there are mixed NonScanBasedAggregationOperator and regular AggregationOperator across different segments. We need to find a way to prevent that

public static SortedLongDistinctSet fromValues(long[] values, int size, boolean sortedDistinct) {
if (!sortedDistinct) {
Arrays.sort(values, 0, size);
size = dedupeSorted(values, size);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to dedup here if it is always constructed from a dictionary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — dictionary values are unique by construction, so the dedupe was redundant. Removed it; fromValues now only sorts when the dictionary is insertion-ordered, and the doc states the input must be duplicate-free. The generic addAll(LongCollection) path keeps its dedupe since that input can be any collection.

import org.apache.pinot.spi.query.QueryThreadContext;


/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(minor) Switch to markdown style

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, converted the doc comments to markdown style.

longSet.add(dictionary.getLongValue(dictId));
long value = dictionary.getLongValue(dictId);
if (dictId > 0 && value <= longValues[dictId - 1]) {
longSorted = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to scan. Dictionary.isSorted() will return this info. It should never be mis-reported (or never false positive)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — dropped the scan and now pass dictionary.isSorted() through. Also updated the operator test to stub isSorted() for the sorted/unsorted cases.

- Use Dictionary.isSorted() instead of scanning for sortedness
- Drop the dedupe in fromValues: dictionary values are unique by
  construction, so only the sort is needed for unsorted (mutable)
  dictionaries
- Switch doc comments to markdown style
Within one query, some segments can take the no-scan path (producing
SortedLongDistinctSet) while others take the scan path (producing
LongOpenHashSet), e.g. a filter that matches all documents in only some
segments. Block merge order is nondeterministic, so a hash set could end
up absorbing a sorted set and re-hash every dictionary-sourced value,
losing the sorted-run optimization unpredictably.

Add SortedLongDistinctSet.union(set1, set2) which always makes the
sorted set the absorbing side regardless of operand order, and use it at
all merge sites (BaseDistinctAggregateAggregationFunction and the
SmartHLL/SmartHLLPlus/SmartULL set merges).
@arunkumarucet

Copy link
Copy Markdown
Contributor Author

Regarding the mixed NonScanBasedAggregationOperator / AggregationOperator concern: addressed in 975e25c. The problem was that block merge order is nondeterministic, so when segments mix the two paths (e.g. a filter that matches all docs in only some segments), a scan-side LongOpenHashSet could end up as the accumulator and absorb the sorted sets element-by-element, silently losing the optimization.

Added SortedLongDistinctSet.union(set1, set2), which makes the sorted set the absorbing side regardless of which operand it arrives on, and used it at every merge site that can see mixed types (BaseDistinctAggregateAggregationFunction.merge plus the SmartHLL/SmartHLLPlus/SmartULL set merges). Absorbing a hash set costs one sort+dedupe of that segment's values, which is comparable to what the hash merge did, and merge cost is now deterministic regardless of block arrival order. Added tests covering both operand orders at the helper level and through DistinctCountAggregationFunction.merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality query Related to query processing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants