Skip to content

[perf] Reduce allocations in multi-stage group-by merging - #19601

Open
xiangfu0 wants to merge 1 commit into
apache:masterfrom
xiangfu0:codex/miq-quintile-broker-allocation
Open

xiangfu0 wants to merge 1 commit into
apache:masterfrom
xiangfu0:codex/miq-quintile-broker-allocation

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

PR flow

Reuse key array and intermediate arrays; optimize ID maps per type and TypeUtils to cut allocations.

flowchart TD
  N0["processMerge#58; alloc outer intermediate results array#44; calls generateGroupByKeys #40;F5#41;"]:::stModified
  N1["generateGroupByKeys #40;multi#41;#58; extract columns once#44; reuse key array per row #40;F5#41;"]:::stModified
  N2["IntToIdMap#46;put#58; use putIfAbsent#44; check INVALID#95;KEY #40;F3#41;"]:::stModified
  N3["LongToIdMap#46;put#58; use putIfAbsent#44; check INVALID#95;KEY #40;F4#41;"]:::stModified
  N4["FloatToIdMap#46;put#58; use putIfAbsent#44; check INVALID#95;KEY #40;F2#41;"]:::stModified
  N5["DoubleToIdMap#46;put#58; use putIfAbsent#44; check INVALID#95;KEY #40;F1#41;"]:::stModified
  N6["TypeUtils#46;convert#58; reuse wrapper if same type #40;F6#41;"]:::stModified
  N0 -->|"calls"| N1
  classDef stAdded fill:#dafbe1,stroke:#1a7f37,color:#1f2328,stroke-width:2px
  classDef stModified fill:#fff8c5,stroke:#9a6700,color:#1f2328,stroke-width:2px
  classDef stRemoved fill:#ffebe9,stroke:#cf222e,color:#1f2328,stroke-width:2px
  classDef stUnchanged fill:#f6f8fa,stroke:#656d76,color:#1f2328,stroke-width:1px
Loading

AI-generated · Green: added · Yellow: modified · Red: removed · Gray: existing

Diff evidence
  • F1: pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/utils/DoubleToIdMap.java — before · after
  • F2: pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/utils/FloatToIdMap.java — before · after
  • F3: pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/utils/IntToIdMap.java — before · after
  • F4: pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/utils/LongToIdMap.java — before · after
  • F5: pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultistageGroupByExecutor.java — before · after
  • F6: pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/utils/TypeUtils.java — before · after
  • Regenerate PR flow

Summary

Serialized multi-stage GROUP BY merging (MultistageGroupByExecutor.processMerge) allocated a separate composite-key array for every input row, eagerly allocated intermediate-result inner arrays that were immediately overwritten, and created temporary numeric wrapper objects during dictionary insertion and result conversion. These allocations grow with partial-row and group counts.

This PR makes four independent changes:

  1. Reuse one composite-key scratch array (generateGroupByKeys, multi-column path). Extract each key column once with the existing extractKey helper, then fill a single reused Object[numKeys] per row instead of allocating a fresh array per row via extractKeys. Safe because the composite-key group generators copy out dictionary IDs and do not retain the passed array (same contract as the single-value row-heap path).
  2. Allocate only the outer intermediate-result array (processMerge). Change new Object[numFunctions][numRows] to new Object[numFunctions][]; the inner arrays were pure garbage — overwritten on the next line by getIntermediateResults(...).
  3. Primitive putIfAbsent in the numeric ID maps (IntToIdMap/LongToIdMap/FloatToIdMap/DoubleToIdMap). Replace computeIfAbsent(v, k -> numValues) (a capturing lambda) with putIfAbsent(v, numValues), and switch the "was-absent" test from == numValues to the INVALID_KEY sentinel — the old test could falsely report "absent" when an existing key legitimately mapped to id numValues.
  4. Reuse numeric wrappers when the type already matches (TypeUtils.convert). Return the existing wrapper (value instanceof Integer ? value : ...) for INT/LONG/FLOAT/DOUBLE instead of always unboxing and re-boxing.

Null handling, numeric conversions, floating-point key behavior and group limits are unchanged.

Per-change performance attribution

Measured with a focused JMH benchmark (BenchmarkGroupByMergeAlloc, not committed) over 100K values, 10K-cardinality, JDK 25, 2 forks × 8 iterations, -prof gc. Same benchmark bytecode linked against base (3b6be9c) vs this PR (20357e2):

Change Benchmark Base alloc PR alloc Δ alloc Base time PR time
#4 TypeUtils.convert convertIntSameType 1,580,018 B/op 1.29 B/op −99.99% 288 µs 185 µs (−36%)
#4 TypeUtils.convert convertDoubleSameType 2,400,003 B/op 1.87 B/op −99.99% 380 µs 269 µs (−29%)
#3 *ToIdMap.put idMapPutInt 431,406 B/op 431,407 B/op ~0% 912 µs 945 µs
#3 *ToIdMap.put idMapPutLong 730,905 B/op 730,905 B/op ~0% 1269 µs 1246 µs

Interpretation of each change's contribution:

  • quick-start-offline.sh return to error "URI is not hierarchical" #4 (TypeUtils.convert) is the dominant allocation win. For already-correctly-typed values it eliminates essentially all re-boxing garbage (~1.5 MB per 100K INT, ~2.4 MB per 100K DOUBLE → ~0) and is ~30% faster on that path. This is the change most directly responsible for the merge-level allocation reduction.
  • How is this different from Druid? #3 (*ToIdMap) contributes correctness and dispatch cleanup, not measurable heap savings. In the microbenchmark the allocation is dominated by fastutil map/array growth as the dictionary fills and is unchanged; the removed capturing lambda is JIT-hoisted or negligible here. Its concrete value is the INVALID_KEY sentinel fix (avoids a false-absent when a key maps to id numValues) plus avoiding lambda dispatch on the hot insert path.
  • add pinot-trace #1 (composite-key scratch reuse) is understated by JMH and needs the full executor to observe. In isolation the small per-row Object[] is scalarized by escape analysis (both patterns measured ~0 B/op), so a microbenchmark cannot reproduce the win. In production, key escapes into getGroupId(...) across a call boundary escape analysis cannot defeat, so the per-row array does allocate and reuse removes N−1 of N such arrays. Measured only via the real-executor probe below.
  • Comment typo in last SQL example. #2 (lazy intermediate arrays) removes numFunctions wasted Object[numRows] allocations per merged block; small and constant per block, folded into the executor-level probe.

Validation

  • 41 focused/existing cases pass: NumericToIdMapTest, TypeUtilsTest, MultistageGroupByExecutorTest, and AggregateOperatorTest.
  • Affected-module Spotless, license and Checkstyle checks pass; no compiler warnings on added lines.
  • A local real-executor probe with 1,463,737 serialized partial rows and 914,051 INT/DOUBLE groups measured 382.29 → 287.18 MB allocated per merge/result operation (24.9% less). Across two JVM forks with 16 measured runs per arm, median thread CPU was 370.69 → 359.52 ms and median wall time was 427.52 → 384.33 ms. This end-to-end operator reduction is consistent with the per-change attribution above (driven mainly by quick-start-offline.sh return to error "URI is not hierarchical" #4 and add pinot-trace #1). Inputs and output validation are outside the measured region; every output key, weight and count is verified. This is local operator evidence, not an end-to-end latency or retained-heap claim.

No public API, wire format, configuration default or permanent benchmark is added.

@codecov-commenter

codecov-commenter commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.81%. Comparing base (3b6be9c) to head (20357e2).

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19601      +/-   ##
============================================
- Coverage     67.83%   67.81%   -0.02%     
  Complexity     1450     1450              
============================================
  Files          3504     3504              
  Lines        226581   226594      +13     
  Branches      35804    35812       +8     
============================================
- Hits         153699   153668      -31     
- Misses        60754    60805      +51     
+ Partials      12128    12121       -7     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (?)
java-25 67.81% <100.00%> (-0.02%) ⬇️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.81% <100.00%> (-0.02%) ⬇️
unittests 67.81% <100.00%> (-0.02%) ⬇️
unittests1 57.99% <100.00%> (+<0.01%) ⬆️
unittests2 39.55% <3.12%> (-0.03%) ⬇️

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 a lite review from Copilot September 20, 2026 19:41
@xiangfu0 xiangfu0 added the performance Related to performance optimization label Sep 20, 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gortiz gortiz 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.

Reviewed the four changes independently. Summary of what I verified and what I'd like changed before merge — none of it blocking, but the javadoc one matters most.

Verified correct

  • The scratch-array reuse is safe against HEAD: both TwoKeysGroupIdGenerator and MultiKeysGroupIdGenerator project the key into ints (long packing / FixedIntArray) before storing anything, so neither retains the passed Object[].
  • extractKey per column and extractKeys produce identical values, including null-bitmap handling, and numRows now comes from dataBlock.getNumberOfRows(), which is what extractKey used internally. No semantic change.
  • The new Object[numFunctions][] change is a pure dead-store removal.
  • TypeUtils.convert identity preservation is unobservable for the four immutable wrapper types, and NPE-on-null behavior is unchanged.
  • putIfAbsent is not slower: Int2IntOpenHashMap overrides it with a single-probe find() + insert() (checked against fastutil 8.5.15 bytecode), so the idMapPutInt delta in the description is noise.

Would like changed

  1. Move the "must not retain the key array" guarantee onto GroupIdGenerator#getGroupId javadoc rather than a caller-side comment. This is the one change here that can silently produce wrong results if a future implementation breaks the contract.
  2. ObjectToIdMap has the byte-identical pattern and wasn't updated — it's the impl used for STRING keys, the most common group-by key type.
  3. The filtered twin generateGroupByKeys(DataBlock, numMatchedRows, matchedBitmap) still pays the full extractKeys cost, leaving two near-identical methods optimized differently.

Description nits

  • The INVALID_KEY change is presented as a correctness fix, but the old id == numValues test looks unreachable by construction (ids are always < size()). Worth softening so reviewers don't hunt for a bug that isn't there.
  • Conversely, change #1's win is understated: it removes the whole Object[][] materialization (~40 MB → ~16 MB for 2 keys × 1M rows), which escape analysis can't account for either way.
  • TypeUtils.convert's hottest callers are LeafOperator:696/733 (per row, per column), not group-by merge — the change is broader than the title suggests.

Details inline.

columns[i] = DataBlockExtractUtils.extractKey(dataBlock, _groupKeyIds[i]);
}
// Multi-column generators retain dictionary IDs, not this array, just as in the row-heap path.
Object[] key = new Object[numKeys];

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.

Please move this guarantee onto the interface.

key is now a single scratch array handed to _groupIdGenerator.getGroupId(key) for every row. That is correct only while no GroupIdGenerator retains the array. I checked both implementations reachable when numKeys >= 2:

  • TwoKeysGroupIdGenerator.getGroupId reads keyValues[0]/[1], maps them to ints and packs them into a long before touching _groupIdMap. Nothing keeps the array.
  • MultiKeysGroupIdGenerator.getGroupId copies into a fresh int[] keyIds and stores new FixedIntArray(keyIds). Nothing keeps the array.

So the change is correct against HEAD, and it makes the serialized path match what the row-heap overload already does.

My concern is durability, not current correctness. A future generator that caches or stores the incoming Object[] — e.g. an Object2IntOpenHashMap<Object[]> with a custom hash strategy, which is a natural thing to write — would corrupt every group, and no existing test would obviously point at the cause. Could the guarantee go onto GroupIdGenerator#getGroupId as javadoc? Something like "the key array is scratch and may be mutated after this call returns; implementations must not retain it." That is where the next implementer will look; a comment on the caller is invisible to them.

intKeys[i] = _groupIdGenerator.getGroupId(keys[i]);
}
} else {
Object[][] columns = new Object[numKeys][];

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.

The allocation win here is larger, and different in kind, from what the description claims — worth correcting because it currently undersells the change.

The description frames this as "reuse removes N-1 of N per-row arrays, hidden from JMH by escape analysis". That is only half of it. The old extractKeys(dataBlock, _groupKeyIds) materialized an Object[numRows][] outer array plus numRows inner Object[numKeys] arrays, all live at once. The new code materializes numKeys column arrays of numRows each.

For 2 keys over 1M rows, roughly:

  • before: 1M * (16B header + 16B refs) + 8MB outer ≈ 40 MB
  • after: 2 * (16B + 8MB)16 MB

That reduction is escape-analysis-proof and does not depend on the scratch-array argument at all — it is simply the removal of the Object[][] materialization. I would lead with that instead of the "JMH understates it" narrative.

Secondary bonus: extraction is now column-major, which suits extractValue's access pattern better than the old row-major nesting.

for (int i = 0; i < numKeys; i++) {
key[i] = columns[i][rowId];
}
intKeys[rowId] = _groupIdGenerator.getGroupId(key);

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.

The filtered twin of this method was left out — deliberate, or an oversight?

generateGroupByKeys(DataBlock, int numMatchedRows, RoaringBitmap matchedBitmap) (a few methods below) is the same shape and still does:

keys = DataBlockExtractUtils.extractKeys(dataBlock, _groupKeyIds, numMatchedRows, matchedBitmap);
for (...) intKeys[i] = _groupIdGenerator.getGroupId(keys[i]);

Its row-heap sibling already uses the reused-scratch pattern, so the same reasoning applies verbatim, and extractKey(dataBlock, colId, numMatchedRows, matchedBitmap) already exists. That path runs for filtered aggregations (COUNT(*) FILTER (WHERE ...)), which is not a rare shape.

Either do it in this PR (~10 lines, same argument) or note in the description why it was deferred. Otherwise the file is left with two near-identical methods optimized differently, which is the kind of drift that later gets copied in the wrong direction.

int numRows = groupByKeys.length;
int numFunctions = _aggFunctions.length;
Object[][] intermediateResults = new Object[numFunctions][numRows];
Object[][] intermediateResults = new Object[numFunctions][];

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.

Safe dead-store removal, no behavior change — the inner arrays were unconditionally overwritten on the very next statement.

Two things worth stating explicitly for future readers: this is only safe because getIntermediateResults assigns every index and never returns null (it does), and nothing downstream depends on intermediateResults[j] having exactly numRows entries — the merge loops index by row and would have gone out of bounds under the old code too if the lengths disagreed. So the old allocation was not acting as a safety net.

int id = _valueToIdMap.computeIfAbsent(value, k -> numValues);
if (id == numValues) {
int id = _valueToIdMap.putIfAbsent(value, numValues);
if (id == INVALID_KEY) {

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.

Two points on this one.

1. I don't think the old code could actually misbehave, so I'd soften the correctness claim in the description.

The description says the old id == numValues test "could falsely report absent when an existing key legitimately mapped to id numValues". By construction that looks unreachable: ids are handed out as the map size at insert time and _idToValueMap grows in lockstep, so after n inserts the ids in the map are exactly {0..n-1} while numValues == size() == n. An existing key therefore always satisfies id < numValues. There is no concurrency either — one ValueToIdMap per key column per generator, single-threaded opchain.

putIfAbsent + INVALID_KEY is clearer and drops a capturing lambda, which is reason enough to make the change. But presenting it as a latent bug fix will send reviewers hunting for a bug that isn't there.

2. ObjectToIdMap has the identical pattern and was not updated.

Same package, same shape: computeIntIfAbsent(value, k -> numValues) followed by if (id == numValues). That is the implementation ValueToIdMapFactory returns for STRING, BYTES and BIG_DECIMAL — i.e. the most common group-by key type in practice. Whatever justifies the change here applies there at least as strongly. Could it be included, or the omission explained?

int numValues = _valueToIdMap.size();
int id = _valueToIdMap.computeIfAbsent(value, k -> numValues);
if (id == numValues) {
int id = _valueToIdMap.putIfAbsent(value, numValues);

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.

FYI for anyone worried about the 912 -> 945 us in the description's idMapPutInt row: I checked, and the swap is not a slowdown.

I was suspicious because Int2IntMap.putIfAbsent has a default method that does get(), then containsKey(), then put() — two or three probes, where Int2IntOpenHashMap.computeIfAbsent is one. But the open-hash-map class overrides it. Disassembling fastutil 8.5.15:

public int putIfAbsent(int, int);
   invokespecial find:(I)I
   iflt  -> insert(-pos-1, k, v); return defRetValue
   else  -> return value[pos]

One find, same as computeIfAbsent. The Float/Double/Long variants are generated from the same template.

So this change is allocation-neutral and cost-neutral, and the ~3.6% delta is run-to-run noise rather than a regression. Worth saying so in the description so nobody blocks on that table row.

switch (storedType) {
case INT:
return ((Number) value).intValue();
return value instanceof Integer ? value : ((Number) value).intValue();

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 is the strongest change in the PR, and its biggest beneficiary is not group-by merging — the title undersells the blast radius.

TypeUtils.convert has six callers. Two of them, LeafOperator:696 and LeafOperator:733, run it per row per column on every single-stage → multi-stage boundary crossing. That is a far hotter path than MultistageGroupByExecutor:269, which runs once per output group. So this helps every MSE query with a leaf stage. Widening the title or at least the description would help whoever bisects a behavior change here later.

Correctness looks fine: the four cases now preserve identity for an already-correctly-typed box, all four wrapper types are immutable so aliasing is unobservable except via ==, and convertRow mutates the row array rather than the values. NPE behavior on a null value is unchanged (the instanceof is false, the cast to Number then NPEs) and the new test pins that.

Non-blocking design thought: the fact that nearly all values on the INT/DOUBLE path are already the right type suggests the conversion call itself is the waste, not the boxing. A cheaper shape would be for convertRow to precompute per column whether any conversion is possible at all and skip the switch entirely — which would also drop the switch dispatch this keeps.

}

@Test(dataProvider = "mergeModes")
public void testSerializedKeysAcrossBlocks(int numKeys, boolean leafReturnFinalResult) {

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.

Good test, and aimed at the right surface: numKeys=2 routes to TwoKeysGroupIdGenerator and numKeys=3 to MultiKeysGroupIdGenerator, both over a serialized block, which is exactly the path the scratch-array reuse touches. If a generator ever started retaining the key array, every row after the first would collapse into a single group and this fails loudly. The multi-block, empty-block and null/NaN/-0.0 mix is a nice touch.

One gap: newExecutor always passes new int[]{-1} for filterArgIds and -1 for maxFilterArgId, so processAggregateWithFilter and the filtered generateGroupByKeys(DataBlock, numMatchedRows, matchedBitmap) are never reached. That is consistent with those staying untouched — but if you take the suggestion to optimize the filtered path too, this test will need a filtered variant.

Float.MAX_VALUE, Float.POSITIVE_INFINITY, Float.NaN}},
{new DoubleToIdMap(), new Object[]{Double.NEGATIVE_INFINITY, -Double.MAX_VALUE, -0.0d, 0.0d, Double.MIN_VALUE,
Double.MAX_VALUE, Double.POSITIVE_INFINITY, Double.NaN}},
{new FloatToIdMap(), new Object[]{Float.intBitsToFloat(0x7fc00001)}},

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.

These two rows look like they pin NaN canonicalization, but as written they don't: each is a one-element map, so the only assertion is "the first put returns 0".

Float.intBitsToFloat(0x7fc00001) and Double.longBitsToDouble(0x7ff8000000000001L) are non-canonical NaN bit patterns. The interesting question they gesture at is whether the map treats them as the same key as Float.NaN/Double.NaN — i.e. whether fastutil keys on floatToIntBits (canonicalizing, matching Float.equals) or floatToRawIntBits (not canonicalizing). Because each lives in its own data-provider row with its own fresh map, the two NaNs never coexist and nothing is compared.

If pinning that is the intent, put the canonical and non-canonical NaN in the same values array and assert whichever behavior HEAD actually has. If it isn't the intent, I'd drop the two rows — they add runtime and a false sense of coverage.

(The -0.0d / 0.0d pair in the row above is meaningful, since those share a map.)

}

@Test(dataProvider = "numericMaps")
public void testNumericKeys(ValueToIdMap map, Object[] values) {

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.

The class is named NumericToIdMapTest, but ObjectToIdMap gets no coverage — and that is both the impl backing STRING/BYTES/BIG_DECIMAL keys and the one left on the old computeIntIfAbsent pattern.

The invariants this test checks — contiguous ids, stable id on repeat put, get(id) round-trip, behavior across map growth — apply to it unchanged. Adding a row is nearly free and would give the one untouched implementation the same regression net, whether or not you also switch it to putIfAbsent.

Minor: the if (map instanceof IntToIdMap) ... else if ... chain in the growth loop is a little brittle. Passing a boxing function alongside the map in the data provider would read better and scale as types are added.

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

Labels

performance Related to performance optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants