xiangfu0 commented on code in PR #19264:
URL: https://github.com/apache/pinot/pull/19264#discussion_r4148023554


##########
pinot-core/src/main/java/org/apache/pinot/core/util/GroupByUtils.java:
##########
@@ -87,6 +100,230 @@ public static GroupByResultsBlock 
buildGroupingSetsResultsBlock(QueryContext que
     return resultsBlock;
   }
 
+  /// Derives the individual grouping sets from a merged BASE-grouping 
[IndexedTable] (union columns aggregated
+  /// once, like a plain GROUP BY), for a GROUP BY GROUPING SETS / ROLLUP / 
CUBE query using base aggregation.
+  /// Each base group is projected into every grouping set: its rolled-up 
(non-participating) columns are set to
+  /// `null`, the `$groupingId` discriminator is inserted after the union 
columns, and the base group's
+  /// aggregation intermediates are merged into the derived group. This moves 
the per-set fan-out from O(rows) to
+  /// O(base groups) and runs it here -- after the row-collapsing base merge 
-- across the combine's threads,
+  /// rather than expanding every scanned row.
+  ///
+  /// The grouping sets are partitioned round-robin across up to `numTasks` 
worker tasks; each task derives its
+  /// owned sets over all base entries into a task-local plain HashMap. Task 
key spaces are disjoint (every
+  /// derived key carries its set ordinal), so the heavy merge work runs 
contention-free -- no concurrent table,
+  /// no bin locks, no shared size counters -- and the task maps are then 
unioned single-threaded into the result
+  /// table with plain stores (never merges). Profiling showed the previous 
shared-concurrent-table derive spent
+  /// most of its time in ConcurrentHashMap machinery rather than in the 
projection itself.
+  ///
+  /// Clone discipline: a base group's intermediate flows into every grouping 
set (and is read concurrently by
+  /// other tasks), while [AggregationFunction#merge] mutates/returns its 
arguments. Each derived group's stored
+  /// accumulator is therefore a clone (see [#cloneIntermediate]), and OBJECT 
intermediates are re-cloned per
+  /// merge so a merge can never mutate the shared base accumulator another 
task is still reading. Scalar
+  /// intermediates are immutable and clone-free.
+  public static IndexedTable 
deriveGroupingSetsFromMergedBaseTable(IndexedTable baseTable, QueryContext 
queryContext,
+      int numTasks, ExecutorService executorService) {
+    AggregationFunction[] aggregationFunctions = 
queryContext.getAggregationFunctions();
+    assert aggregationFunctions != null;
+    int numAggregationFunctions = aggregationFunctions.length;
+    List<int[]> groupingSets = queryContext.getGroupingSets();
+    int numSets = groupingSets.size();
+    int numUnionColumns = queryContext.getGroupByExpressions().size();
+    // Per grouping set: membership mask over the union columns (true = 
participates, false = rolled up to NULL).
+    boolean[][] setContains = new boolean[numSets][numUnionColumns];
+    for (int s = 0; s < numSets; s++) {
+      for (int columnIndex : groupingSets.get(s)) {
+        setContains[s][columnIndex] = true;
+      }
+    }
+
+    // Grouping-set output schema: the base schema with the synthetic 
$groupingId INT column inserted right after
+    // the union group-by columns (mirroring GroupByOperator's grouping-set 
schema layout).
+    DataSchema groupingSetsSchema = 
insertGroupingIdColumn(baseTable.getDataSchema(), numUnionColumns);
+    // The derive must not drop groups: it is a bounded transformation of the 
already-bounded base groups (whose
+    // count is capped at numGroupsLimit per segment), so the derived count is 
at most numGroupsLimit * numSets --
+    // a finite amount. Capping the derived table at numGroupsLimit would drop 
derived groups NON-DETERMINISTICALLY
+    // under the parallel upsert (whichever threads fill the quota first win), 
which can starve an entire
+    // low-magnitude grouping set such as the grand total. Use an unbounded 
result size here and defer the real
+    // ORDER BY + LIMIT to the broker (per-set trim below still bounds it when 
explicitly configured).
+    int derivedUpperBound = (int) Math.min((long) baseTable.size() * numSets, 
Integer.MAX_VALUE);

Review Comment:
   Fixed the heap/output concern directly in a9b179e: the parallel per-set maps 
run only when their worst-case total fits max(numGroupsLimit, numSets). 
Otherwise one coarsest-first map is capped during derivation, and later 
FULL-layout records cannot add keys beyond it. This keeps retained grand 
totals/subtotals complete while flagging dropped fine groups. The cardinality 
gate and opt-in default remain in place. The PR body now documents this cap and 
the resulting approximation.



##########
pinot-core/src/main/java/org/apache/pinot/core/util/GroupByUtils.java:
##########
@@ -87,6 +100,230 @@ public static GroupByResultsBlock 
buildGroupingSetsResultsBlock(QueryContext que
     return resultsBlock;
   }
 
+  /// Derives the individual grouping sets from a merged BASE-grouping 
[IndexedTable] (union columns aggregated
+  /// once, like a plain GROUP BY), for a GROUP BY GROUPING SETS / ROLLUP / 
CUBE query using base aggregation.
+  /// Each base group is projected into every grouping set: its rolled-up 
(non-participating) columns are set to
+  /// `null`, the `$groupingId` discriminator is inserted after the union 
columns, and the base group's
+  /// aggregation intermediates are merged into the derived group. This moves 
the per-set fan-out from O(rows) to
+  /// O(base groups) and runs it here -- after the row-collapsing base merge 
-- across the combine's threads,
+  /// rather than expanding every scanned row.
+  ///
+  /// The grouping sets are partitioned round-robin across up to `numTasks` 
worker tasks; each task derives its
+  /// owned sets over all base entries into a task-local plain HashMap. Task 
key spaces are disjoint (every
+  /// derived key carries its set ordinal), so the heavy merge work runs 
contention-free -- no concurrent table,
+  /// no bin locks, no shared size counters -- and the task maps are then 
unioned single-threaded into the result
+  /// table with plain stores (never merges). Profiling showed the previous 
shared-concurrent-table derive spent
+  /// most of its time in ConcurrentHashMap machinery rather than in the 
projection itself.
+  ///
+  /// Clone discipline: a base group's intermediate flows into every grouping 
set (and is read concurrently by
+  /// other tasks), while [AggregationFunction#merge] mutates/returns its 
arguments. Each derived group's stored
+  /// accumulator is therefore a clone (see [#cloneIntermediate]), and OBJECT 
intermediates are re-cloned per
+  /// merge so a merge can never mutate the shared base accumulator another 
task is still reading. Scalar
+  /// intermediates are immutable and clone-free.
+  public static IndexedTable 
deriveGroupingSetsFromMergedBaseTable(IndexedTable baseTable, QueryContext 
queryContext,
+      int numTasks, ExecutorService executorService) {
+    AggregationFunction[] aggregationFunctions = 
queryContext.getAggregationFunctions();
+    assert aggregationFunctions != null;
+    int numAggregationFunctions = aggregationFunctions.length;
+    List<int[]> groupingSets = queryContext.getGroupingSets();
+    int numSets = groupingSets.size();
+    int numUnionColumns = queryContext.getGroupByExpressions().size();
+    // Per grouping set: membership mask over the union columns (true = 
participates, false = rolled up to NULL).
+    boolean[][] setContains = new boolean[numSets][numUnionColumns];
+    for (int s = 0; s < numSets; s++) {
+      for (int columnIndex : groupingSets.get(s)) {
+        setContains[s][columnIndex] = true;
+      }
+    }
+
+    // Grouping-set output schema: the base schema with the synthetic 
$groupingId INT column inserted right after
+    // the union group-by columns (mirroring GroupByOperator's grouping-set 
schema layout).
+    DataSchema groupingSetsSchema = 
insertGroupingIdColumn(baseTable.getDataSchema(), numUnionColumns);
+    // The derive must not drop groups: it is a bounded transformation of the 
already-bounded base groups (whose
+    // count is capped at numGroupsLimit per segment), so the derived count is 
at most numGroupsLimit * numSets --
+    // a finite amount. Capping the derived table at numGroupsLimit would drop 
derived groups NON-DETERMINISTICALLY
+    // under the parallel upsert (whichever threads fill the quota first win), 
which can starve an entire
+    // low-magnitude grouping set such as the grand total. Use an unbounded 
result size here and defer the real
+    // ORDER BY + LIMIT to the broker (per-set trim below still bounds it when 
explicitly configured).
+    int derivedUpperBound = (int) Math.min((long) baseTable.size() * numSets, 
Integer.MAX_VALUE);
+    int initialCapacity = getIndexedTableInitialCapacity(derivedUpperBound, 
derivedUpperBound,
+        queryContext.getMinInitialIndexedTableCapacity());
+
+    List<Map.Entry<Key, Record>> baseEntries = new 
ArrayList<>(baseTable.getRecordEntries());
+    int numTaskSlots = Math.max(1, Math.min(numTasks, numSets));
+    List<Map<Key, Record>> taskMaps;
+    if (numTaskSlots == 1) {
+      taskMaps = List.of(deriveSets(baseEntries, setContains, 0, 1, 
numUnionColumns, numAggregationFunctions,
+          aggregationFunctions));
+    } else {
+      List<Future<Map<Key, Record>>> futures = new ArrayList<>(numTaskSlots);
+      for (int t = 0; t < numTaskSlots; t++) {
+        int taskIndex = t;
+        futures.add(executorService.submit(() -> deriveSets(baseEntries, 
setContains, taskIndex, numTaskSlots,
+            numUnionColumns, numAggregationFunctions, aggregationFunctions)));
+      }
+      taskMaps = new ArrayList<>(numTaskSlots);
+      try {
+        for (Future<Map<Key, Record>> future : futures) {
+          taskMaps.add(future.get());
+        }
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new RuntimeException("Interrupted while deriving grouping sets", 
e);
+      } catch (ExecutionException e) {
+        for (Future<Map<Key, Record>> future : futures) {
+          future.cancel(true);
+        }
+        throw new RuntimeException("Caught exception while deriving grouping 
sets", e.getCause());
+      }
+    }
+
+    // Union the disjoint task maps into the result table. Since task key 
spaces are disjoint, the union never
+    // merges records. In the common case, adopt the largest task map as the 
table's backing map and putAll the
+    // rest into it, so the largest map's keys are never re-hashed. The 
deterministic accurate-group-by mode
+    // needs a sorted (skip-list) backing map for deterministic iteration 
order, so it keeps the upsert-based
+    // union into the table produced by getTrimDisabledIndexedTable 
(single-threaded, numThreads = 1).
+    IndexedTable derivedTable;
+    if (useDeterministicIndexedTable(queryContext)) {
+      derivedTable = getTrimDisabledIndexedTable(groupingSetsSchema, false, 
queryContext, Integer.MAX_VALUE,
+          initialCapacity, 1, executorService);
+      for (Map<Key, Record> taskMap : taskMaps) {
+        for (Map.Entry<Key, Record> entry : taskMap.entrySet()) {
+          derivedTable.upsert(entry.getKey(), entry.getValue());
+        }
+      }
+    } else {
+      Map<Key, Record> mergedMap = taskMaps.get(0);
+      for (Map<Key, Record> taskMap : taskMaps) {
+        if (taskMap.size() > mergedMap.size()) {
+          mergedMap = taskMap;
+        }
+      }
+      for (Map<Key, Record> taskMap : taskMaps) {
+        if (taskMap != mergedMap) {
+          mergedMap.putAll(taskMap);
+        }
+      }
+      derivedTable = new SimpleIndexedTable(groupingSetsSchema, false, 
queryContext, Integer.MAX_VALUE,
+          Integer.MAX_VALUE, Integer.MAX_VALUE, mergedMap, executorService);
+    }
+
+    /// Optional server-side per-set trim: when configured (and there is an 
ORDER BY), keep at most K groups
+    /// within each grouping set (bucketed by $groupingId), bounding this 
server's derived output for
+    /// high-cardinality unions. Bucketing per set means a global top-K cannot 
starve a low-magnitude set. This
+    /// is an approximate top-K (deferred exact ORDER BY + LIMIT still runs at 
the broker); unset -> keep all.
+    int serverTrimSize = queryContext.getGroupingSetServerTrimSize();
+    if (serverTrimSize > 0 && derivedTable.size() > (long) serverTrimSize * 
numSets) {
+      derivedTable.finish(false);
+      TableResizer tableResizer = new TableResizer(groupingSetsSchema, 
queryContext);
+      List<IntermediateRecord> kept = 
tableResizer.trimTableByGroupingSet(derivedTable, serverTrimSize,
+          numUnionColumns);
+      
ServerMetrics.get().addMeteredGlobalValue(ServerMeter.AGGREGATE_TIMES_GROUPS_TRIMMED,
 1);
+      return buildIndexedTableFromRecords(groupingSetsSchema, queryContext, 
kept, executorService);
+    }
+    return derivedTable;
+  }
+
+  /// Builds a trim-disabled grouping-set [IndexedTable] pre-populated with 
the given (already unique) records.
+  /// Used to materialize the per-set-trim survivors back into the table the 
combine returns.
+  private static IndexedTable buildIndexedTableFromRecords(DataSchema 
dataSchema, QueryContext queryContext,
+      List<IntermediateRecord> records, ExecutorService executorService) {
+    int numRecords = records.size();
+    int initialCapacity =
+        getIndexedTableInitialCapacity(numRecords, numRecords, 
queryContext.getMinInitialIndexedTableCapacity());
+    // Populated single-threaded, so use a plain-HashMap-backed table 
(numThreads = 1).
+    IndexedTable table = getTrimDisabledIndexedTable(dataSchema, false, 
queryContext, Integer.MAX_VALUE,
+        initialCapacity, 1, executorService);
+    for (IntermediateRecord record : records) {
+      table.upsert(record._key, record._record);
+    }
+    return table;
+  }
+
+  /// One derive task: projects every base group into the grouping sets owned 
by `taskIndex` (set `s` is owned
+  /// when `s % numTaskSlots == taskIndex`) and aggregates the derived groups 
into a task-local map. Each task
+  /// runs single-threaded over its own map, so a plain HashMap suffices; task 
key spaces are disjoint because
+  /// every derived key ends with its set ordinal.
+  ///
+  /// The first record stored for a derived key holds cloned intermediates (it 
becomes the group's accumulator,
+  /// mutated by later merges); on merge, OBJECT intermediates from the shared 
base group are cloned again so the
+  /// merge can never mutate a base accumulator that other tasks are still 
reading. Scalar intermediates are
+  /// immutable and pass through uncloned.
+  private static Map<Key, Record> deriveSets(List<Map.Entry<Key, Record>> 
baseEntries, boolean[][] setContains,
+      int taskIndex, int numTaskSlots, int numUnionColumns, int 
numAggregationFunctions,
+      AggregationFunction[] aggregationFunctions) {
+    int numSets = setContains.length;
+    // A single set can produce at most one derived group per base entry, so 
this covers the largest owned set
+    // (typically the near-identity one) without rehashing; coarser owned sets 
add far fewer distinct groups.
+    Map<Key, Record> taskMap = new 
HashMap<>(HashUtil.getHashMapCapacity(baseEntries.size()));

Review Comment:
   The full-union-only pre-sizing fix is already in the branch. The remaining 
set-partition skew is a measured tradeoff in the safe parallel path: the 
earlier range-partitioned shared table was slower due to contention, and 
reducing the identity task’s hash work did not change end-to-end time in the 
recorded JMH run. a9b179e uses a serial bounded path only when worst-case 
fan-out would exceed the output cap; I am not claiming a new performance win 
for that path. No further pre-sizing change is needed here.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java:
##########
@@ -252,6 +253,26 @@ public int getNumExtraGroupByKeyColumns() {
     return isGroupingSets() ? 1 : 0;
   }
 
+  /// Returns whether grouping-set queries should aggregate the base grouping 
(union columns) once per segment
+  /// and derive the individual grouping-set records from those base groups, 
instead of expanding every input
+  /// row into one group per grouping set. Enabled by default; the 
`groupingSetsBaseAggregation=false` query
+  /// option forces the legacy per-row expansion path. See 
[CommonConstants.Broker.Request.QueryOptionKey].
+  ///
+  /// Only the standard 
[org.apache.pinot.core.operator.combine.GroupByCombineOperator] performs the 
merge-time
+  /// derive that turns the emitted base groups back into grouping-set 
records. Base aggregation is therefore
+  /// disabled whenever a different combine path would run: filtered 
aggregations (distinct shared group-key
+  /// generator), the streaming combine used on MSE leaf stages 
(`streamingGroupByFlushThreshold > 0`, which
+  /// flushes incrementally and cannot derive over the fully-merged base 
table), and the sorted-aggregate combine
+  /// ([#shouldSortAggregateUnderSafeTrim()]). In all these cases the segment 
expands per row so the emitted rows
+  /// already carry the $groupingId discriminator.
+  public boolean isGroupingSetsBaseAggregation() {

Review Comment:
   Addressed in a9b179e. Query-level exclusions remain centralized in 
QueryContext.isGroupingSetsBaseAggregation; MV/cardinality decisions remain per 
segment. The standard combine now validates FULL blocks, and sorted, sequential 
sorted, and streaming combines reject grouping-set blocks missing the correctly 
typed $groupingId column before merging. A regression checks fail-closed 
handling. Base aggregation also remains opt-in/off by default.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to