Extend FUNNEL_COUNT to support multiple CORRELATE_BY columns#18760
Extend FUNNEL_COUNT to support multiple CORRELATE_BY columns#18760tarun11Mavani wants to merge 6 commits into
Conversation
Performance Validation (JMH)Ran Single-key path — Before (baseline) vs After (this PR):
*theta_sketch and partitioned_sorted show large error bars indicating JVM warmup variance, not a real regression. Scores overlap within error margins. Multi-key path (new feature, this PR only):
Single-key path shows NO statistically significant regression. All deltas are within error margins. The bitmap/set/partitioned strategies (which dominate real workloads) are within ±2% of baseline — effectively identical. |
e1d2196 to
d6bb092
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #18760 +/- ##
============================================
- Coverage 64.77% 64.74% -0.03%
Complexity 1322 1322
============================================
Files 3393 3393
Lines 211022 211316 +294
Branches 33135 33216 +81
============================================
+ Hits 136687 136816 +129
- Misses 63322 63459 +137
- Partials 11013 11041 +28
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:
|
| int[] dictIds = new int[numKeys]; | ||
| while (iterator.hasNext()) { | ||
| wrapper.reverseCompositeId(iterator.next(), dictIds); | ||
| valueBitmap.add(DictIdsWrapper.toCompositeString(wrapper._dictionaries, dictIds).hashCode()); |
There was a problem hiding this comment.
DictIdsWrapper.toCompositeString(wrapper._dictionaries, dictIds).hashCode())
This now generates a theoretical hash collision so bitmap strategy for multikey correlation is not accurate anymore.
We will have to either call this out as limitation in docs or find an alternative.
There was a problem hiding this comment.
This is actually an existing limitation of the bitmap strategy, not something new from multi-key. The single-key path in convertToValueBitmap already uses .hashCode() for LONG, FLOAT, DOUBLE, and STRING types — only INT gets exact values stored directly. The multi-key path is consistent: toCompositeString itself is collision-free (length-prefix encoding is injective), but the .hashCode() mapping to 32-bit int has the same collision properties as single-key STRING at line 109.
I've updated the method Javadoc on convertCompositeToValueBitmap to call this out more explicitly, linking it to the existing single-key non-INT approximation.
There was a problem hiding this comment.
Let's document this for multi-key int values it can be approximate.
There was a problem hiding this comment.
yes. already covered in the class/method Javadoc.
There was a problem hiding this comment.
I don't understand the point about all the meticulous process of keeping maps, reversing, building composite strings and what have you, we are anyways getting a hash code here.
There was a problem hiding this comment.
Make sense. I'll replace the toCompositeString().hashCode() with a direct hash-combining loop over the dictionary values.
|
|
||
| @Override | ||
| void addMultiKey(UpdateSketch[] stepsSketches, int step, Dictionary[] dictionaries, int[] correlationDictIds) { | ||
| stepsSketches[step].update(DictIdsWrapper.toCompositeString(dictionaries, correlationDictIds)); |
There was a problem hiding this comment.
I think there will be a lot of new string creation cost and subsequent GC pressure with toCompositeString for each row.
Similarly at other places if cardinality of distinct correlation multi-keys is high in a query.
There was a problem hiding this comment.
Fair point — toCompositeString does allocate a new StringBuilder + String per row. A couple of things to note though:
- Theta sketch's
update()only accepts primitives (int,long,double) orString/byte[]. Since a multi-key tuple has no single primitive representation, some form of serialization is unavoidable here. - The single-key STRING path (line 75) already allocates a string per row via
dictionary.getStringValue(), so the cost pattern is structurally similar — just slightly more overhead from the length-prefix encoding. - JMH baseline for multi-key theta_sketch is 287 ops/s, which we can measure future optimizations against.
One option would be switching to update(byte[]) with a reusable ByteBuffer to avoid the String intermediate, but wanted to keep it simple for the initial implementation. Do you have other optimization ideas in mind?
|
|
||
| @Override | ||
| void addMultiKey(DictIdsWrapper dictIdsWrapper, int step, Dictionary[] dictionaries, int[] correlationDictIds) { | ||
| dictIdsWrapper._stepsBitmaps[step].add(dictIdsWrapper.getCompositeCorrelationId(correlationDictIds)); |
There was a problem hiding this comment.
Is it possible to cache/memoize getCompositeCorrelationId, this gets computed for every step that matches for a multi-key
There was a problem hiding this comment.
On the stride path (common case), getCompositeCorrelationId is just multiply-add arithmetic — caching costs more than it saves (array comparison overhead > a few multiplies).
On the HashMap path (rare overflow case), repeated map lookups per step do cost more, but this path is already exceptional. And the number of repeated calls per row is bounded by the number of matching steps (typically 2-5).
Hence, I feel we are better off without any cache here.
|
Please address the comments, overall LGTM. |
Enable funnel analysis that tracks users through steps within a composite key (e.g., per user per device category) by accepting multiple columns in CORRELATE_BY(col1, col2, ...). The single-key path is preserved as a zero-overhead fast path with separate addSingleKey/addMultiKey abstract methods and dedicated aggregation loops, ensuring no regression for existing single-column queries. Multi-key composite ID mapping uses stride-based arithmetic when the product of dictionary sizes fits in int, with a HashMap fallback for large key spaces. Co-authored-by: Cursor <cursoragent@cursor.com>
Benchmark was used for local validation only; not needed in the PR. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the original `add(Dictionary, A, int, int)` abstract method unchanged. The new multi-key method is added as `addMultiKey(A, int, Dictionary[], int[])`. Co-authored-by: Cursor <cursoragent@cursor.com>
…egationResult double-count - Add DictIdsWrapperTest covering the HashMap fallback path (large-cardinality composite keys where product of dict sizes exceeds Integer.MAX_VALUE): path selection, sequential ID assignment, same-key idempotency, key-order sensitivity, and round-trip for 2- and 3-column keys. Also covers stride-path reverseCompositeId round-trip. Add isHashMapPath() predicate to DictIdsWrapper for test introspection (avoids widening _strides visibility). - Add SortedAggregationResultTest with multi-key extraction scenarios. - Fix SortedAggregationResult.extractResult(): clear _secondaryKeySteps after flushMultiKeyGroup() so a second call (defensive) returns zeros rather than double-counting the last open primary group.
Add method-level doc on convertCompositeToValueBitmap linking the multi-key .hashCode() usage to the existing single-key non-INT approximation in convertToValueBitmap.
e30af27 to
b21be3a
Compare
|
cc @shauryachats @Jackie-Jiang @xiangfu0 to review. |
| /** | ||
| * Creates an aggregation result for multi-key correlation. | ||
| */ | ||
| abstract A createAggregationResultMultiKey(Dictionary[] dictionaries); |
There was a problem hiding this comment.
I think we can start with this for now, but it would be better in my opinion if we do not have a physical column requirement on secondary correlation keys.
I think for the primary correlation key it makes sense that we require it to be a simple dictionary encoded column, but for secondary correlation I believe we could allow for any expression including projections.
Again, I think it is fine to start with this restriction for this pull request, but would be great to follow-up with a generalization.
There was a problem hiding this comment.
make sense. We'll keep the physical column restriction for this PR and follow up with expression support for secondary keys.
| * Maps a tuple of per-column dictionary IDs to a single composite int suitable for RoaringBitmap. | ||
| * Only used for multi-key; for single-key, callers should add the dictId directly. | ||
| */ | ||
| int getCompositeCorrelationId(int[] dictIds) { |
There was a problem hiding this comment.
In my opinion for composite case we should use 64 bits (i.e. long). The first 32 bits for the primary correlation key, the other 32 bits for the secondary correlation keys.
There was a problem hiding this comment.
To make sure I understand: with ((long) primaryDictId << 32) | hash32(secondaryDictIds), the result is a 64-bit value, but RoaringBitmap only stores 32-bit ints. Are you suggesting we:
(a) use Roaring64Bitmap instead,
(b) hash/cast the long down to 32 bits for the bitmap,
or (c) something else?
Also — this makes the set strategy and partitioned-bitmap strategy approximate for multi-key (they're exact today since composite IDs are collision-free). We need to agree on this and document it.
There was a problem hiding this comment.
not sure if we need 64 bits to be honest, but the main point is that the only scenarios that are collision free today are the partitioned strategies and the set for ints, all others convert the value (e.g. a uuid string) to a 32 bits hash.
Even for the strategies where we use dict IDs today, we still need to explain the collision semantics in the doc for multi key case. I was thinking that the contract could be that you would never have smaller counts due to collisions when adding a secondary correlation key compared to having only a primary correlation key. Say counting by user should never be larger than counting by user+device.
| if (existingId != null) { | ||
| return existingId; | ||
| } | ||
| IntArrayList insertKey = new IntArrayList(dictIds); |
There was a problem hiding this comment.
All of this memory allocation and subsequent garbage collection seems to me completely unnecessary.
We do not have a collision free counting strategy anyways.
There was a problem hiding this comment.
agreed.
If we adopt the approach from above comment, this entire HashMap path goes away. If we keep it, I can at least eliminate the double-store (IntArrayList + dictIds.clone()). Will address based on how we resolve comment 2.
| _secondaryKeySteps.clear(); | ||
| } | ||
|
|
||
| _lookupKey.clear(); |
There was a problem hiding this comment.
Consider simply _lookupKey = IntArrayList.wrap(correlationIds)
There was a problem hiding this comment.
correlationIds includes the primary key at index 0 (we only need secondary keys from index 1+), and wrap() aliases the underlying array which gets mutated on the next row
There was a problem hiding this comment.
All I am trying to find is a way to avoid allocations. There must be ways to reuse fast util maps or perhaps have array lookups for scenarios up to say 5 entries. Teams mostly use secondary correlation for things like order id, mobile session if, device id, etc, but it is very rare to have multiple simultaneous trips per user, or multiple sessions or devices per user, and even when it happens we usually talk about a small number. I think we need as much as possible to optimise for this in the sorted case.
| boolean[] steps = _secondaryKeySteps.get(_lookupKey); | ||
| if (steps == null) { | ||
| steps = new boolean[_numSteps]; | ||
| _secondaryKeySteps.put(new IntArrayList(_lookupKey), steps); |
There was a problem hiding this comment.
Why do we need to create a clone of lookupKey here? Are we mutating it somewhere?
There was a problem hiding this comment.
The clone is needed because _lookupKey is a reusable buffer that gets clear()+add() on every row. Without the clone, the HashMap key mutates in-place on the next row.
There was a problem hiding this comment.
I see you try to remove the sorting key from the array, but it is innocuous, it's the same for every entry here. The cost of all this moving around and specially the cost of memory allocation and garbage collection is much higher than adding one more int to a hash code calculation or equality calculation which is all that you aim to achieve here.
|
my main concern is to avoid memory allocations for partitioned sorted strategy. |
…map extraction SortedAggregationResult: replace HashMap<IntArrayList, boolean[]> with pre-allocated flat arrays and linear scan. Zero allocations in the hot loop for typical workloads (1-5 secondary key combos per primary group). BitmapResultExtractionStrategy: replace toCompositeString().hashCode() with direct type-aware hash combining, avoiding StringBuilder/String allocation per composite ID during extraction.
f1adfa8 to
e72173d
Compare
Summary
Extends
FUNNEL_COUNTto accept multiple columns inCORRELATE_BY(col1, col2, ...),enabling funnel analysis that tracks users through steps within a composite key
(e.g., per user per device category), not just a single dimension.
Design
Doc with example: https://docs.google.com/document/d/1gWQ7XBbJdQcUdZvBevFnGTVbCVJ3fN49biIsSOtRdhM/edit?tab=t.0
The single-key aggregation path is preserved as a zero-overhead fast path — structurally
identical to the original single-column implementation — so existing queries see no
regression. Multi-key support is added as a separate code path selected once per block.
AggregationStrategy: Split into two abstract methods (addSingleKey/addMultiKey)with separate aggregation loops for single-key and multi-key, eliminating per-row branching
on the dominant single-key path.
DictIdsWrapper: Added composite-key mapping for multi-column CORRELATE_BY. Usesstride-based arithmetic when the product of dictionary sizes fits in
int, falling backto a
HashMap<IntArrayList, Integer>for large key spaces. Also addstoCompositeStringfor length-prefix encoded composite string keys used during result extraction.
SortedAggregationResult: Updated to handle multi-key by tracking secondary keys viaa
HashMapwithin each primary-key group (data is sorted on the primary column only).BitmapAggregationStrategy,SortedAggregationStrategy,ThetaSketchAggregationStrategy: Implement bothaddSingleKeyandaddMultiKey.SetResultExtractionStrategy,BitmapResultExtractionStrategy: Updated toreverse-map composite IDs back to per-column dictionary values during result extraction.
FunnelCountSortedAggregationFunction: Propagates multi-dictionary context throughthe sorted aggregation result extraction pipeline.
Example Query
HashMap Fallback Usage
The composite-key mapping in
DictIdsWrapperuses stride-based arithmetic when the product of per-segment dictionary sizes fits inint, falling back to aHashMap<IntArrayList, Integer>only when that product exceedsInteger.MAX_VALUE. In practice the fallback is rarely exercised:CORRELATE_BYis one high-cardinality key (e.g.,user_id) correlated with a low-cardinality dimension (e.g.,device_category,country,platform). With a second key in the typical low-cardinality range, the primary key can hold a large number of distinct values per segment before overflow:These ceilings sit well above realistic per-segment cardinalities for the high-cardinality key, so the stride path covers the common case. The
HashMapfallback only kicks in for unusual queries that correlate two genuinely high-cardinality columns in the same segment, and exists purely to keep those queries correct rather than fast.Test Plan