Skip to content

Optimize GROUP BY GROUPING SETS/ROLLUP/CUBE via base aggregation - #19264

Open
xiangfu0 wants to merge 6 commits into
apache:masterfrom
xiangfu0:cs_9FQh4kAdDX/grouping-sets-base-aggregation
Open

Optimize GROUP BY GROUPING SETS/ROLLUP/CUBE via base aggregation#19264
xiangfu0 wants to merge 6 commits into
apache:masterfrom
xiangfu0:cs_9FQh4kAdDX/grouping-sets-base-aggregation

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Summary

GROUP BY GROUPING SETS / ROLLUP / CUBE previously expanded every input row into one group per grouping set in each segment, so the per-set fan-out cost scaled with the number of scanned rows (O(rows * numSets)). This makes ROLLUP/CUBE several times slower than a plain GROUP BY over the same columns, even though the results are derivable from a single base grouping.

This PR aggregates only the base grouping (the union of all grouping-set columns) once per segment — reusing the fast plain-GROUP-BY path — and then derives the individual grouping sets from the merged base groups, in parallel, at combine time. The per-set fan-out moves from O(rows) (per segment, single-threaded) to O(base groups) (once, multi-threaded).

Approach

Segment phase. A base-aggregation grouping-set query aggregates only the union columns, exactly like a plain GROUP BY, and emits the base groups — no per-segment expansion, no $groupingId column.

Combine phase. GroupByCombineOperator merges base groups across segments into a base-keyed IndexedTable, then in mergeResults() derives the grouping sets once: for each base group and each grouping set, it projects the base key (rolled-up columns → NULL), stamps the $groupingId discriminator, and merges the base group's aggregation intermediates into the derived group. The derivation is partitioned across the combine thread pool by base-group ranges into a shared concurrent grouping-set table, so the fan-out is multi-threaded and runs after the row-collapsing base merge rather than repeating per-row.

This reuses the existing AggregationFunction#merge machinery the combine/reduce phases already rely on, so it is exact for every mergeable aggregation. Because a base group's intermediate flows into every grouping set (and across threads) and merge mutates/returns its argument, each base intermediate is cloned per derived record (via the function's serialize/deserialize round-trip; scalar intermediates are immutable and skipped), keeping object-backed accumulators (AVG, DISTINCTCOUNT, percentiles, ...) correct.

Trimming. The derive is a bounded transformation of the already-bounded base groups (base is capped at numGroupsLimit per segment, so derived ≤ numGroupsLimit * numSets), so it does not re-apply that per-segment guardrail — doing so would drop derived groups non-deterministically under the parallel upsert and could starve a low-magnitude set such as the grand total. The real ORDER BY + LIMIT is deferred to the broker.

Behavior change

The base-aggregation path is enabled by default via a new groupingSetsBaseAggregation query option. Set groupingSetsBaseAggregation=false to force the legacy per-row expansion path. Results are identical to the expansion path (verified by equivalence tests).

Carve-outs that fall back to the expansion path:

  • Multi-value group-by columns — an MV column fans a row across its values in the base grouping, which would over-count when that column is rolled up.
  • Filtered aggregations — these share a single group-key generator across aggregation groups via a distinct segment path.

Also included

Improvements to the legacy expansion generator (used for the fallback cases and groupingSetsBaseAggregation=false):

  • Resolves dictionary-encoded columns via native dict-ids instead of re-hashing raw values.
  • Packs composite keys into a primitive long when they fit, using a Long2IntOpenHashMap instead of Object2IntOpenHashMap<FixedIntArray> (avoids per-group object allocation and array hashing).
  • Reuses per-row group-id buffers across blocks.

A general fix in IndexedTable: it now derives its key-column count from the schema (columns − aggregations) rather than always from getNumGroupByKeyColumns(), so the base combine table (whose records omit the $groupingId column) merges aggregations at the correct offset.

A new JMH benchmark BenchmarkGroupingSetsQueriesSSE exercises the full server→broker flow.

Benchmark

Single-stage engine, 50 segments × 15k rows, LIMIT 10000, base aggregation vs. groupingSetsBaseAggregation=false (per-row expansion). JMH AverageTime, 2 forks × 5 iterations (10 samples); ± is the 99.9% error.

Low / moderate cardinality (EXP(0.5)):

Query Expansion (ms) Base aggregation (ms) Speedup
ROLLUP(D1, D2) 12.57 ± 0.77 4.74 ± 0.51 2.65x
ROLLUP(D1, D2, D3) 23.86 ± 1.28 11.72 ± 0.67 2.04x
CUBE(D1, D2, D3) 34.94 ± 1.11 12.95 ± 0.44 2.70x
GROUPING SETS ((D1),(D2),(D3),(D1,D2),()) 18.21 ± 1.07 10.01 ± 0.70 1.82x

High cardinality (EXP(0.001)), where base groups ≈ rows so there is little to collapse:

Query Expansion (ms) Base aggregation (ms) Ratio
ROLLUP(D1, D2) 229.2 ± 11.7 274.8 ± 5.3 0.83x
ROLLUP(D1, D2, D3) 282.3 ± 7.6 276.4 ± 11.9 1.02x
CUBE(D1, D2, D3) 436.8 ± 12.1 432.2 ± 7.4 1.01x
GROUPING SETS ((D1),(D2),(D3),(D1,D2),()) 248.9 ± 7.8 223.5 ± 4.8 1.11x

Takeaways: base aggregation is a 1.8–2.7x win at low/moderate cardinality (the common OLAP case, where many rows collapse into few base groups) and roughly parity at extreme cardinality (base groups ≈ rows, so the base grouping is as expensive as the scan and the derive fan-out is comparable to expansion's per-row work). The one mild high-cardinality regression (ROLLUP(D1, D2), 0.83x) sits in that near-unique regime.

Testing

GroupingSetsQueriesTest (75 tests) passes, including:

  • Base-aggregation vs. expansion equivalence across DISTINCTCOUNT, AVG, SUM over CUBE/ROLLUP/GROUPING SETS shapes, with and without null handling (covers the empty-object-intermediate path that exercises the per-set clone).
  • Existing ROLLUP/CUBE/GROUPING SETS/GROUPING()/GROUPING_ID()/null-handling/MV cases.

DeriveGroupingSetsTest (unit) covers the parallel derive:

  • Identical results across thread counts {1, 2, 4, 8}.
  • Cross-segment base-group merge (reproduces an ArrayIndexOutOfBoundsException without the IndexedTable key-column fix).
  • No group dropped / deterministic output even when derived groups exceed a small numGroupsLimit.

Pre-commit checks (spotless, license, checkstyle) pass on all touched modules.

@codecov-commenter

codecov-commenter commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 280 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.08%. Comparing base (f5fee8e) to head (20fa894).
⚠️ Report is 17 commits behind head on master.

Files with missing lines Patch % Lines
...egation/groupby/GroupingSetsGroupKeyGenerator.java 0.00% 101 Missing ⚠️
.../java/org/apache/pinot/core/util/GroupByUtils.java 0.00% 91 Missing ⚠️
...org/apache/pinot/core/data/table/TableResizer.java 0.00% 40 Missing ⚠️
...che/pinot/core/operator/query/GroupByOperator.java 0.00% 17 Missing ⚠️
.../core/operator/combine/GroupByCombineOperator.java 0.00% 11 Missing ⚠️
...pinot/core/query/request/context/QueryContext.java 0.00% 11 Missing ⚠️
...ry/aggregation/groupby/DefaultGroupByExecutor.java 0.00% 6 Missing ⚠️
...org/apache/pinot/core/data/table/IndexedTable.java 0.00% 2 Missing ⚠️
...ore/query/aggregation/groupby/GroupByExecutor.java 0.00% 1 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (f5fee8e) and HEAD (20fa894). Click for more details.

HEAD has 2 uploads less than BASE
Flag BASE (f5fee8e) HEAD (20fa894)
unittests1 1 0
unittests 2 1
Additional details and impacted files
@@              Coverage Diff              @@
##             master   #19264       +/-   ##
=============================================
- Coverage     67.12%   39.08%   -28.05%     
+ Complexity     1424     1423        -1     
=============================================
  Files          3462     3463        +1     
  Lines        220677   220942      +265     
  Branches      35255    35310       +55     
=============================================
- Hits         148136    86351    -61785     
- Misses        60708   126719    +66011     
+ Partials      11833     7872     -3961     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (?)
java-25 39.08% <0.00%> (-28.05%) ⬇️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 39.08% <0.00%> (-28.05%) ⬇️
unittests 39.08% <0.00%> (-28.05%) ⬇️
unittests1 ?
unittests2 39.08% <0.00%> (-0.05%) ⬇️

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.

@xiangfu0
xiangfu0 requested review from Jackie-Jiang and yashmayya and a lite review from Copilot August 15, 2026 09:01
@xiangfu0 xiangfu0 added aggregation Related to aggregation functions and operations query Related to query processing labels Aug 15, 2026

Copilot AI 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.

Pull request overview

This PR optimizes single-stage GROUP BY GROUPING SETS / ROLLUP / CUBE by aggregating the base (union) grouping once per segment using the standard fast GROUP BY path, then deriving per-grouping-set rows from those base groups (with defensive cloning of object intermediates to keep merges correct). It also improves the legacy expansion key generator for the fallback path and adds test/benchmark coverage for the new behavior.

Changes:

  • Add a new query option (groupingSetsBaseAggregation, enabled by default) and plumb executor/operator logic to use base aggregation + derivation where applicable.
  • Implement segment-level derivation of grouping-set rows from base groups (including per-set segment trimming parity with the legacy path).
  • Improve the legacy expansion generator performance (native dict-id fast path + optional long-packed key storage) and add equivalence tests + a new JMH benchmark.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java Adds the groupingSetsBaseAggregation query option key.
pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java Adds isGroupingSetsBaseAggregation() decision logic (default-on, with filtered-agg carve-out).
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DefaultGroupByExecutor.java Centralizes base-vs-expansion selection and exposes it via the executor.
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/GroupByExecutor.java Adds an executor flag to indicate base aggregation was used for grouping sets.
pinot-core/src/main/java/org/apache/pinot/core/operator/query/GroupByOperator.java Switches grouping-set result building to the base-derivation path when indicated by the executor.
pinot-core/src/main/java/org/apache/pinot/core/util/GroupByUtils.java Implements derivation from base groups with defensive cloning and per-set segment trimming.
pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java Adds per-set bucketing trim for already-built IntermediateRecords (used by the derive path).
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/GroupingSetsGroupKeyGenerator.java Improves legacy expansion key generation (native dict-id fast path + optional long packing).
pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/GroupingSetsQueriesTest.java Adds equivalence tests to ensure base aggregation matches legacy expansion across key scenarios.
pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkGroupingSetsQueriesSSE.java Adds a JMH benchmark covering end-to-end SSE grouping sets and base-vs-expansion comparison.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +109 to +111
// use 8 threads to test combine parallelism
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(8);

@Jackie-Jiang Jackie-Jiang added enhancement Improvement to existing functionality performance Related to performance optimization labels Aug 15, 2026
@Jackie-Jiang
Jackie-Jiang requested a balanced review from Copilot August 15, 2026 19:23

Copilot AI 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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java:267

  • This makes a new behavior-changing execution path active whenever the option is absent. Pinot's rollout convention requires new feature flags to default off so production workloads can opt in and validate them before the default changes; the fallback alone does not prevent an immediate blast-radius change. Default this option to false initially and update the related documentation/benchmark description accordingly.
    String option = _queryOptions.get(CommonConstants.Broker.Request.QueryOptionKey.GROUPING_SETS_BASE_AGGREGATION);
    return option == null || Boolean.parseBoolean(option);


// Derived group table keyed on (projected union values..., $groupingId). Values layout mirrors the record
// schema: key columns first, then the aggregation intermediates.
Map<Key, Record> derived = new HashMap<>();
int packedId = extractPackedId(key, i);
keys[i] = packedId == _nullPackedIds[i] ? null : _dictionaries[i].getInternal(packedId);
}
keys[_numGroupByExpressions] = (int) (key >>> _bitShifts[_numGroupByExpressions]);
@xiangfu0

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Addressed all three inline comments in fc597a0:

1. GroupingSetsGroupKeyGenerator — shift-by-64 ($groupingId) bug. Long-packing is now disabled when the union columns consume all 64 bits (the discriminator shift would be >= 64), so the shift stays strictly below 64 and Java's mod-64 shift masking can't turn a shift of 64 into 0. Those queries fall back to the correct Object2IntOpenHashMap path. Good catch on the full-width / single-set boundary.

2. GroupByUtils — derived table unbounded by numGroupsLimit. The derived map is now bounded by numGroupsLimit while it is built: once the limit is reached, brand-new derived keys are skipped while existing groups keep accumulating — mirroring the generator's per-segment cap so the per-set fan-out (up to numSets × base groups) can't exhaust heap before the trim runs. The derived fan-out is also folded into the numGroupsLimitReached / numGroupsWarningLimitReached flags.

3. BenchmarkGroupingSetsQueriesSSE — static executor shut down in @TearDown. Replaced with a per-trial executor created in @Setup and shut down in @TearDown, so its lifecycle matches each JMH trial and later @Param combinations don't submit to a shut-down pool.

On the suppressed comment about defaulting groupingSetsBaseAggregation off: it's currently default-on. I'm happy to flip it to default-off for the first release if the maintainers prefer the standard opt-in-then-flip rollout — let me know your preference and I'll update the option default plus the doc/benchmark descriptions.

Note: the failing SegmentDeletionManagerTest.testRemoveDeletedSegments in "Unit Test Set 2" is a pre-existing flake (120s async-filesystem-deletion timeout in pinot-controller) unrelated to this PR, which touches only pinot-core / pinot-spi / pinot-perf / integration-tests. I re-ran the failed job.

@xiangfu0
xiangfu0 force-pushed the cs_9FQh4kAdDX/grouping-sets-base-aggregation branch from fc597a0 to c64a993 Compare August 16, 2026 09:04
@xiangfu0

Copy link
Copy Markdown
Contributor Author

Pushed d91f07a making base aggregation cardinality-adaptive so it's a strict win in both regimes.

Why: base aggregation only pays off when rows collapse into far fewer base groups. For high-cardinality union columns the base grouping barely collapses the rows, so it adds a base pass + a derive pass on top of the same output — benchmarks showed CUBE over near-unique columns ~2x slower than expansion.

How: before the scan, estimate the base-group count as the product of the union columns' dictionary cardinalities (saturating to Long.MAX_VALUE on overflow, or when any union column is non-dictionary-encoded and its cardinality is unknown). If the estimate exceeds a new groupingSetsBaseAggregationMaxGroups query option (default = numGroupsLimit), fall back to per-row expansion.

Result (50 segments × 15k rows, base-agg default vs. forced expansion):

Query Low-card (EXP(0.5)) speedup High-card (EXP(0.001))
ROLLUP(D1,D2) 5.2 vs 12.9 ms — 2.48x 228.8 vs 228.0 ms — parity
ROLLUP(D1,D2,D3) 13.8 vs 24.0 ms — 1.74x 287.7 vs 273.8 ms — parity
CUBE(D1,D2,D3) 18.3 vs 35.9 ms — 1.96x 468 vs 453 ms — parity (was ~2x slower before the gate)
GROUPING SETS (5) 8.6 vs 19.9 ms — 2.31x 248.7 vs 245.7 ms — parity

Low-cardinality keeps the 1.7–2.5x win; high-cardinality now matches expansion instead of regressing.

Also folded in the earlier review fixes from the same round: the groupingSetsBaseAggregationMaxGroups option is parsed via QueryOptionsUtils (clear error naming the option on a malformed value), plus a unit test for the estimate (EstimateBaseGroupCountTest: product / non-dictionary / overflow / zero-cardinality branches) and an integration test for the cardinality-gated fallback.

@xiangfu0
xiangfu0 force-pushed the cs_9FQh4kAdDX/grouping-sets-base-aggregation branch 2 times, most recently from b137d09 to 5d132e9 Compare August 18, 2026 09:04
@xiangfu0 xiangfu0 added needs-attention Used for sensitive changes - allows searching PRs post release to narrow down causes for regression. data-integrity Related to correctness of data or query results labels Aug 19, 2026
@xiangfu0

Copy link
Copy Markdown
Contributor Author

Pushed eba1229, which reworks base aggregation to do the grouping-set fan-out once, in parallel, at combine time — removing the need for the cardinality gate entirely (base aggregation is now a strict win in all regimes).

Change: segments emit only the BASE groups (union grouping, no $groupingId). GroupByCombineOperator merges base groups across segments, then in mergeResults() derives the individual grouping sets once, parallelized across the combine thread pool by base-group ranges into a shared concurrent grouping-set table. Total work is minimal (each row grouped once, never per set) and the fan-out is multi-threaded. The prior groupingSetsBaseAggregationMaxGroups gate/option is removed. Object intermediates are cloned per derived record so concurrent cross-set/cross-thread merges stay exact.

Benchmark (50 segments × 15k rows, base aggregation vs. groupingSetsBaseAggregation=false expansion):

Query Low-card EXP(0.5) High-card EXP(0.001)
ROLLUP(D1,D2) 1.3 vs 13.4 ms — ~10x 2.5 vs 231.6 ms — ~91x
ROLLUP(D1,D2,D3) 1.5 vs 24.0 ms — ~16x 10.8 vs 286.7 ms — ~27x
CUBE(D1,D2,D3) 1.5 vs 37.7 ms — ~25x 11.2 vs 462.2 ms — ~41x
GROUPING SETS (5) 1.6 vs 19.1 ms — ~12x 11.3 vs 246.4 ms — ~22x

High-cardinality CUBE went from ~2x slower (the regression the gate protected against) to ~40x faster than expansion. (Small-dataset micro-numbers with wide error bars, but the order-of-magnitude difference is unambiguous.)

Correctness fix (found by review): IndexedTable now derives its key-column count from the schema (columns − aggregations) instead of always from getNumGroupByKeyColumns(). The base combine table's records omit the $groupingId column, so the old fixed count merged aggregations at the wrong offset — dropping the first aggregation's cross-segment contribution and reading out of bounds when a base key repeated across segments. Added DeriveGroupingSetsTest, which exercises the parallel derive across thread counts {1,2,4,8} and the cross-segment base merge; it reproduces the ArrayIndexOutOfBoundsException without the fix and passes with it.

All 75 grouping-sets integration tests + the new unit tests pass; spotless/license/checkstyle clean.

@xiangfu0

Copy link
Copy Markdown
Contributor Author

Pushed ad2b4ba fixing a trimming bug in the parallel derive that this design surfaced.

Bug: the derive built its output table with resultSize = numGroupsLimit. For a grouping-set query without ORDER BY, the concurrent upsert caps at that size and drops brand-new keys once full — so which derived groups survived depended on thread interleaving (non-deterministic), and an entire low-magnitude grouping set (e.g. the grand total) could be starved if the detail set filled the quota first.

Fix: the derive is a bounded transformation of the already-bounded base groups (base is capped at numGroupsLimit per segment, so derived ≤ numGroupsLimit × numSets), so it must not re-apply that per-segment guardrail. The derived table is now built with an unbounded result size; the real ORDER BY + LIMIT is deferred to the broker, and per-segment memory is still bounded upstream by the base-grouping numGroupsLimit cap. In other words: trimming is disabled on the derive and only the broker trims — consistent with the existing "grouping-set queries must not trim per server" rule.

Added a regression test (DeriveGroupingSetsTest) that sets a small numGroupsLimit, then asserts the grand-total set is never dropped and that parallel derive is deterministic across runs. It fails before the fix (non-deterministic output) and passes after. All 75 grouping-sets integration tests still pass.

@intentlab-ai
intentlab-ai Bot force-pushed the cs_9FQh4kAdDX/grouping-sets-base-aggregation branch from ad2b4ba to b359df2 Compare August 20, 2026 00:34
Grouping-set queries previously expanded every input row into one group per
grouping set in each segment, so the per-set fan-out cost scaled with the number
of scanned rows (O(rows * numSets)). This makes ROLLUP/CUBE several times slower
than a plain GROUP BY over the same columns even though the results are derivable
from a single base grouping.

This change aggregates only the base grouping (the union of all grouping-set
columns) once per segment -- reusing the fast plain-GROUP-BY path -- then derives
the individual grouping-set records from those base groups by projecting rolled-up
columns to NULL, stamping the $groupingId discriminator, and merging the base
groups' aggregation intermediates. The per-set fan-out moves from O(rows) to
O(base groups).

Because a base group's intermediate result flows into every grouping set and
AggregationFunction#merge mutates/returns its argument, each base intermediate is
cloned per set (via the function's serialize/deserialize round-trip; scalar
intermediates are immutable and skipped) before it can become a merge target, so
object-backed accumulators (AVG, DISTINCTCOUNT, percentiles, ...) stay exact. The
base path also applies the same per-set bucketed segment trim as the expansion
path.

Behavior change: this path is enabled by default via the new
groupingSetsBaseAggregation query option; set it to false to force the legacy
per-row expansion path. It is not used for multi-value group-by columns (an MV
column fans a row across its values in the base grouping, which would over-count
when rolled up) or filtered aggregations (distinct shared-generator path); both
fall back to expansion.

Also improves the legacy expansion generator (used for the fallback cases and
groupingSetsBaseAggregation=false): resolves dictionary-encoded columns via native
dict-ids, packs composite keys into a primitive long when they fit (avoiding
per-group FixedIntArray allocation and array hashing), and reuses per-row group-id
buffers across blocks.

Adds a JMH benchmark (BenchmarkGroupingSetsQueriesSSE) and integration tests that
compare the base-aggregation and expansion paths for equivalence across
scalar/object accumulators and query shapes, plus coverage for the long-packed
generator path.
…executor

- GroupByUtils: bound the derived grouping-set map by numGroupsLimit while
  building it (the per-set fan-out can multiply base groups by up to numSets),
  instead of only trimming afterward; account the derived fan-out in the
  numGroups limit/warning flags.
- GroupingSetsGroupKeyGenerator: disable long-packing when the union columns
  consume all 64 bits, so the $groupingId shift stays strictly below 64 (Java
  masks long shifts mod 64, which would otherwise turn a shift of 64 into 0 and
  corrupt the packed/unpacked ordinal).
- BenchmarkGroupingSetsQueriesSSE: use a per-trial executor created in @setup
  and shut down in @teardown rather than a static one, so multiple JMH @PARAM
  trials in the same JVM do not submit to a shut-down pool.
Base aggregation only pays off when input rows collapse into far fewer base
groups; for high-cardinality union columns it adds a base pass plus a derive
pass on top of the same output and can be slower than per-row expansion
(benchmarks showed CUBE over near-unique columns ~2x slower).

Gate base aggregation on an estimate of the base-group count -- the product of
the union columns' dictionary cardinalities, saturating at Long.MAX_VALUE on
overflow or when any union column is non-dictionary-encoded (unknown
cardinality). When the estimate exceeds the new groupingSetsBaseAggregationMaxGroups
query option (default: numGroupsLimit), fall back to per-row expansion. This
keeps the low-cardinality speedup (~1.7-2.5x) while matching expansion on
high-cardinality inputs.

The threshold option is parsed via QueryOptionsUtils so a malformed value yields
a clear error naming the option. Adds a unit test for the estimate (product,
non-dictionary, overflow, zero-cardinality branches) and an integration test for
the cardinality-gated fallback.
Previously each segment expanded its base groups into all grouping sets before
emitting (per-segment derive), so the combine merged numSegments * baseGroups *
numSets records into the shared table -- and for high-cardinality union columns
(baseGroups ~ rows) this ran slower than plain expansion, which the prior commit
worked around with a cardinality gate.

Instead, segments now emit only the BASE groups (union grouping, no $groupingId),
and GroupByCombineOperator merges those across segments and derives the
individual grouping sets ONCE in mergeResults(), parallelized across the combine
thread pool by base-group ranges into a shared concurrent grouping-set table.
Total work is now minimal (each row grouped once, never per set) and the per-set
fan-out is multi-threaded, so base aggregation is a strict win in all cardinality
regimes; the cardinality gate and its groupingSetsBaseAggregationMaxGroups option
are removed. Object intermediates are cloned per derived record so concurrent
cross-set/cross-thread merges stay exact.

IndexedTable now derives its key-column count from the schema (columns minus
aggregations) instead of always from getNumGroupByKeyColumns(). This fixes a
cross-segment merge bug for the base combine table, whose base records omit the
$groupingId column: the old fixed count merged aggregations at the wrong offset,
dropping the first aggregation's cross-segment contribution and reading out of
bounds. Adds DeriveGroupingSetsTest covering the parallel derive across thread
counts and the cross-segment base merge (which reproduces the out-of-bounds
without the fix).
The parallel combine-phase derive created its output table with
resultSize = numGroupsLimit. For a grouping-set query without ORDER BY the
concurrent upsert caps at that size and drops brand-new keys once full, so which
derived groups survived depended on thread interleaving -- non-deterministic, and
able to starve an entire low-magnitude grouping set (e.g. the grand total) when
the detail set filled the quota first.

The derive is a bounded transformation of the already-bounded base groups (base
count is capped at numGroupsLimit per segment, so derived <= numGroupsLimit *
numSets), so it must not re-apply that per-segment guardrail. Build the derived
table with an unbounded result size and defer the real ORDER BY + LIMIT to the
broker. Adds a regression test asserting the grand-total set is never dropped and
that parallel derive is deterministic even when derived groups exceed a small
numGroupsLimit.
@xiangfu0
xiangfu0 force-pushed the cs_9FQh4kAdDX/grouping-sets-base-aggregation branch from b359df2 to d7a8a08 Compare August 20, 2026 09:11
…gation

Base aggregation defers all ORDER BY trimming to the broker, which is exact but
keeps every derived group (up to numGroupsLimit * numSets) in memory and on the
wire per server. Add a groupingSetsServerTrimSize query option that, for a
base-aggregation grouping-set query WITH an ORDER BY, keeps at most K groups
within each grouping set on the server after the derive -- a per-set top-K
bucketed by the $groupingId discriminator, so a global top-K can never starve a
low-magnitude set such as the grand total. This bounds each server's output for
high-cardinality unions at the cost of an approximate top-K (the broker still
applies the exact final ORDER BY + LIMIT). Unset/non-positive (default) keeps the
exact defer-to-broker behavior; ignored without ORDER BY.

TableResizer.trimTableByGroupingSet applies the bucketed trim over a finished
derived table's records. Adds an integration test asserting a large K is exact
and a small K bounds the output without starving any set (including the grand
total).
@xiangfu0

Copy link
Copy Markdown
Contributor Author

Made grouping-set trimming configurable per stage so accuracy vs. memory/latency can be tuned (pushed 20fa894).

Where trimming happens now (base aggregation):

Stage Cap Notes
Segment scan base groups ≤ numGroupsLimit same memory guardrail as a plain GROUP BY
Server combine — base merge base groups ≤ numGroupsLimit trim-disabled table
Server combine — derive configurable (new) per-set top-K via groupingSetsServerTrimSize, default off (keep all)
Broker reduce ORDER BY + LIMIT the exact, final trim

New knob: groupingSetsServerTrimSize — for a base-aggregation grouping-set query with ORDER BY, keep at most K groups within each grouping set on the server after the derive (bucketed by $groupingId), so a global top-K can never starve a low-magnitude set such as the grand total. This bounds each server's derived output for high-cardinality unions, at the cost of an approximate per-server top-K (the broker still applies the exact final ORDER BY + LIMIT).

  • Default (unset / ≤ 0): keep all derived groups and let only the broker trim — exact, but higher memory/network.
  • Set to K: bounded per-set server output — cheaper, approximate.
  • Ignored without ORDER BY.

The per-set bucketing (reusing the same anti-starvation logic as the legacy expansion path's segment trim) is the key: it never drops an entire grouping set, only ranks within each. Added an integration test asserting a large K is exact (no group dropped) and a small K (K=1) bounds the output while every set — including the grand total — still survives. All 76 grouping-sets tests pass; checkstyle/license/spotless clean.

This gives operators the accuracy/cost dial you asked for; we can add segment-stage or broker-stage variants later on the same pattern if a use case wants them.

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

Labels

aggregation Related to aggregation functions and operations data-integrity Related to correctness of data or query results enhancement Improvement to existing functionality needs-attention Used for sensitive changes - allows searching PRs post release to narrow down causes for regression. performance Related to performance optimization query Related to query processing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants