Replace default group-by combine with non-blocking per-thread operator (CONCURRENT fallback)#14698
Replace default group-by combine with non-blocking per-thread operator (CONCURRENT fallback)#14698xiangfu0 wants to merge 4 commits into
Conversation
7fe387a to
0dd717f
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #14698 +/- ##
============================================
- Coverage 65.37% 57.09% -8.29%
+ Complexity 1405 1 -1404
============================================
Files 3423 2628 -795
Lines 215972 156564 -59408
Branches 34186 25586 -8600
============================================
- Hits 141199 89387 -51812
+ Misses 63399 59515 -3884
+ Partials 11374 7662 -3712
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:
|
caa59d4 to
5eee7a3
Compare
|
@xiangfu0 : are you folks also planning to run some benchmarks? And any other ideas you are already trying out? |
530b877 to
758d7fe
Compare
758d7fe to
fc14886
Compare
|
Updated with commit fc14886. The partitioned combine path now uses thread-local per-partition tables plus parallel final reduction, which removes the 8-thread initialization race and improves the partitioned path on the same local synthetic workload from 106.341 ms to 77.933 ms at 4 threads (~27% faster). On 8 threads the previous implementation reproduced the NPE; the current code completes at 59.218 ms, and in the current 3-way comparison the partitioned path is 54.288 ms vs 68.945 ms for non-blocking and 150.688 ms for default. |
fc14886 to
2dc4573
Compare
7aba9ff to
57771a1
Compare
69f5945 to
70273e1
Compare
78bf1ad to
d5414e3
Compare
Introduce two new server-level group-by combine algorithms selectable via the `groupByAlgorithm` query option: - NONBLOCKING: Uses a lock-free ConcurrentHashMap to aggregate results across segments without holding a global lock, reducing contention under high concurrency. - PARTITIONED: Hash-partitions group keys across independent tables so that each partition can be aggregated and trimmed in parallel with zero cross-partition synchronization. Includes per-partition thread-local accumulation, cached key hashing, and parallel finish/trim to minimize overhead for high-cardinality queries. Also adds: - `normalizeGroupByAlgorithm` helper in QueryOptionsUtils - `IndexedTable.transferFrom` for bulk partition merging - JMH benchmark for comparing combine operator throughput - Unit and integration tests for both new algorithms
…tests - GroupByCombineOperator.ALGORITHM: "DEFAULT" → "CONCURRENT" (descriptive name for the shared ConcurrentHashMap-based operator; "DEFAULT" was misleading since it is no longer the default) - CombinePlanNode: update comment to reference CONCURRENT - InterSegmentNonBlockingGroupBySingleValueQueriesTest: use QueryOptionKey.GROUP_BY_ALGORITHM constant instead of raw string, use NonblockingGroupByCombineOperator.ALGORITHM / GroupByCombineOperator.ALGORITHM constants instead of hardcoded strings, add tests for the CONCURRENT fallback path
- Add Server.GROUP_BY_ALGORITHM config key so operators can set a fleet-wide default algorithm without modifying individual queries (MAJOR: no server-level rollback path). InstancePlanMakerImplV2 reads it at init and falls back to NON-BLOCKING. Per-query SET groupByAlgorithm=... still overrides. - Revert GroupByCombineOperator fields back to private (only _indexedTable was needed by the subclass). Replace direct field access with three protected exchange methods: stealSharedTable(), depositSharedTable(), hasSharedTable(). NonblockingGroupByCombineOperator now uses these methods instead of reaching into the parent's internal state (MINOR: overly wide protected field scope). - Make EXPLAIN_NAME protected in GroupByCombineOperator; remove the duplicate constant and the redundant toExplainString() override from the subclass (MINOR: duplicate constant). - Add Javadoc to IndexedTable.iterator() documenting the pre-finish semantics and to absorbTrimStats() documenting the non-thread-safe ownership requirement (MINOR: undocumented contract change).
…ment accuracy - Replace h^h>>>16 secondary hash with Knuth multiplicative hash (0x9E3779B9) in partitionFor() to eliminate HashMap bucket clustering in partition-local tables - Fix trim storm in maybeTrimLocalPartitions: loop until actualTotal drops below threshold instead of trimming only the largest partition once - Clarify _indexedTable comment to accurately reflect volatile DCL access pattern
Summary
Replace the default group-by combine operator with a non-blocking implementation that uses per-thread
SimpleIndexedTables instead of a sharedConcurrentHashMap, eliminating cross-thread contention.Motivation
The existing
GroupByCombineOperatoruses a singleConcurrentIndexedTablebacked byConcurrentHashMapshared across all worker threads. Under high concurrency this creates significant lock contention — benchmarks show performance actually degrades as threads increase (e.g., 4→8 threads makes queries slower, not faster). The non-blocking approach gives each thread its ownSimpleIndexedTable, merging them at the end, which scales linearly with thread count.How the non-blocking combine works
Old approach (CONCURRENT):
New approach (default, NON-BLOCKING):
Each worker thread creates its own
SimpleIndexedTable(backed by plainHashMap, zero synchronization) and processes segments independently. When a thread finishes all its segments, it merges its local table with others via a lock-free exchange protocol:The merge cost is small relative to processing because:
HashMap.compute(), which is ~3-5x faster per call thanConcurrentHashMap.compute()ConcurrentHashMap.compute()HashMap.compute()Approaches evaluated
We benchmarked several optimization strategies before settling on the non-blocking approach:
1. Partitioned group-by combine (
PartitionedGroupByCombineOperator)Hash-partitions group keys across independent tables with per-partition locks and parallel tree-reduction merge. Showed ~2x speedup at moderate cardinality (10K-100K records/segment) but hit severe GC pressure at high cardinality (1M+) due to N_threads × N_partitions
SimpleIndexedTableinstances. Removed in favor of the simpler non-blocking approach which scales better across all cardinalities.2. fastutil
Object2ObjectOpenHashMapreplacingHashMapBenchmarked open-addressing hash map vs JDK
HashMapfor theIndexedTableupsert hot path. Results (single-thread,GROUP BY d1, d2, d1×d2 cardinality up to 8M):HashMapwins because: (a) JDK'scompute()is heavily optimized, (b) chaining with tree bins degrades more gracefully than linear probing at high load, (c) fastutil's advantage is with primitive types (avoids boxing), notObjectkeys.3. Cached
Key.hashCode()Pre-computed
Arrays.hashCode()at Key construction instead of recomputing per lookup. No measurable benefit — within error margins at all sizes. The JDK likely already caches or the hash computation is not the bottleneck vs map probing/resizing.4. Non-blocking per-thread combine (chosen approach)
Each worker thread builds its own
SimpleIndexedTable(plainHashMap, no synchronization). After processing, threads merge via a lock-free exchange protocol. This eliminates all contention during the hot processing path.(JMH, 32 segments, 8 threads,
GROUP BY d1, d2 ORDER BY SUM(m1) DESC LIMIT 500, d1 cardinality=4096, d2 cardinality=2048)The speedup increases with data volume (1.8x→3.1x) because contention on the shared
ConcurrentHashMapgrows with the number of keys being merged.Key changes
NonblockingGroupByCombineOperator: new default group-by combine operator that uses per-threadSimpleIndexedTables with lock-free merge at the endabsorbTrimStats()onIndexedTable: propagates resize/trim statistics when merging tables so metrics remain accurateCombinePlanNode: routes all group-by queries (except safe-trim sorted path) toNonblockingGroupByCombineOperatorby defaultpinot.server.query.executor.group.by.algorithm=CONCURRENTto revert the default fleet-wide without modifying queriesSET groupByAlgorithm='CONCURRENT'reverts to the legacyConcurrentHashMap-based operator for a single querySortedGroupByCombineOperator/SequentialSortedGroupByCombineOperator) for safe-trim with small limits is preservedAlgorithm names
groupByAlgorithmvalueNonblockingGroupByCombineOperatorNON-BLOCKINGNonblockingGroupByCombineOperatorCONCURRENTGroupByCombineOperatorConcurrentHashMap(fallback)Validation
./mvnw -pl pinot-core -am -Dtest=InterSegmentNonBlockingGroupBySingleValueQueriesTest,InterSegmentGroupBySingleValueQueriesTest,CombinePlanNodeTest,IndexedTableTest,GroupByTrimTest,ExplainPlanQueriesTest -Dsurefire.failIfNoSpecifiedTests=false test