Skip to content

perf: speed up dense count agg with array counting - #559

Open
cheb0 wants to merge 4 commits into
0-materialized-column-field-iterfrom
0-single-source-count-agg-batched
Open

cheb0 wants to merge 4 commits into
0-materialized-column-field-iterfrom
0-single-source-count-agg-batched

Conversation

@cheb0

@cheb0 cheb0 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

  • Aggregator now accepts batches of lids, SourceNodeIterator rerturns a batch of sources
  • MIDs are extracted for a batch of lids
  • SingleSourceCountAggregator now delegates aggregation logic to sourceCounter interface, which has two implementations: plainSourceCounter handles ordinary aggs, uses a chunked array for counting. tsSourceCounter handles timeseries aggs, uses map

Array is better than map when we have many many lids processed in agg, but can be slower when the number of lids is low and agg is high cardinality (we need to allocate an array). This is partially handled by "chunking" the array.

Query env Total cold, ms   hot, ms   cold (branch), ms   hot (branch), ms   cold diff hot diff
service:large | by k8s_pod prod 112107 57.6 ±3.80 18 ±1.33 52.57 ±2.72 13.36 ±0.78 -8.7% -25.8%
service:small | by k8s_pod prod 3211 48.1 ±2.76 7.3 ±1.66 47.89 ±1.66 6.15 ±0.37 -0.4% -15.8%
service:micro | by k8s_pod prod 99 67.64 ±1.60 6.41 ±0.45 66.73 ±1.04 6.66 ±0.53 -1.3% 3.9%
service:large | by (k8s_pod, 60s) prod 112107 122.21 ±2.95 23.04 ±1.86 119.04 ±2.93 21.99 ±0.61 -2.6% -4.6%
service:large | by (level, 60s) prod 112107 106.44 ±1.95 22.83 ±0.20 103.82 ±1.30 21.42 ±0.28 -2.5% -6.2%
request_host:huge | by response_status lb 1065220 84.05 ±3.89 53.89 ±1.09 56.52 ±2.55 29.26 ±0.94 -32.8% -45.7%
request_host:huge | by request_method lb 1065220 78.11 ±2.80 47.77 ±0.72 55.14 ±1.53 28.18 ±0.72 -29.4% -41%
response_status:500 | by remote_addr lb 172 92.18 ±2.95 16.51 ±0.72 90.06 ±3.16 16.53 ±1.02 -2.3% 0.1%
exists:remote_addr | by remote_addr lb 2844073 2027.1 ±47.77 1860.21 ±25.41 1745.57 ±85.87 1538.06 ±90.97 -13.9% -17.3%
request_host:huge | request_uri lb 1065220 100.97 ±3.68 59.22 ±1.42 78.79 ±10.05 34.73 ±3.04 -22% -41.4%
request_host:huge | k8s_service_name lb 1065220 84.42 ±2.12 49.34 ±0.90 64.24 ±1.97 28.53 ±0.63 -23.9% -42.2%
request_host:huge | by x_o3_app_name lb 1065220 88.51 ±1.84 57.45 ±0.72 63.36 ±1.57 31.02 ±0.74 -28.4% -46%

  • I have read and followed all requirements in CONTRIBUTING.md;
  • I used LLM/AI assistance to make this pull request;

If you have used LLM/AI assistance please provide model name and full prompt:

Model: {{model-name}}
Prompt: {{prompt}}

Stack created with GitHub Stacks CLI • Give Feedback 💬

@cheb0
cheb0 added this pull request to stack #515 October 2, 2026 11:01
@codecov-commenter

codecov-commenter commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.81437% with 12 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (0-materialized-column-field-iter@da61d86). Learn more about missing BASE report.

Files with missing lines Patch % Lines
frac/processor/aggregator.go 93.29% 11 Missing ⚠️
frac/processor/search.go 50.00% 1 Missing ⚠️
Additional details and impacted files
@@                         Coverage Diff                         @@
##             0-materialized-column-field-iter     #559   +/-   ##
===================================================================
  Coverage                                    ?   78.18%           
===================================================================
  Files                                       ?      251           
  Lines                                       ?    19284           
  Branches                                    ?        0           
===================================================================
  Hits                                        ?    15077           
  Misses                                      ?     4207           
  Partials                                    ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

🔴 Performance Degradation

Some benchmarks have degraded compared to the previous run.
Click on Show table button to see full list of degraded benchmarks.

Show table
Name Previous Current Ratio Verdict
AggDeep/size=10000-4 70af15 1faa84
0.00 B/op 3.00 B/op NaN 🔴
AggDeep/size=1000000-4 70af15 1faa84
0.00 B/op 30902.00 B/op NaN 🔴
AggWide/size=10000-4 70af15 1faa84
0.00 B/op 3.00 B/op NaN 🔴
AggWide/size=1000000-4 70af15 1faa84
842.00 B/op 34521.00 B/op 41.00 🔴

@cheb0 cheb0 changed the title perf: make aggregators batched perf: speed up dense count agg with array counting Oct 2, 2026
@eguguchkin eguguchkin added this to the v0.81.0 milestone Oct 5, 2026
@eguguchkin
eguguchkin requested review from eguguchkin and forshev and removed request for forshev October 5, 2026 10:28
@dkharms
dkharms self-requested a review October 5, 2026 10:38
@eguguchkin
eguguchkin removed their request for review October 5, 2026 10:55
Comment on lines +299 to +300
for i, mid := range mids {
if sources[i] < 0 {

@dkharms dkharms Oct 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure that sizes of these two arrays (mids and sources) are always identical?

You extract mids based on non-filtered lids:

func (n *SingleSourceCountAggregator) Next(lids []node.LID) error {
	/* ... */
	mids := n.extractMID(lids, n.midsBuf)
	n.midsBuf = mids[:0]
	/* ... */
}

Comment thread frac/processor/aggregator.go Outdated
s.countBySource[s.lastSource]++
if s.uniqSourcesLimit.limit > 0 && len(s.countBySource) > s.uniqSourcesLimit.limit {
return lid.Unpack(), true, fmt.Errorf("%w: iterator limit is exceeded", s.uniqSourcesLimit.err)
s.countBySource[s.lastSource]++

@dkharms dkharms Oct 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here hashmap is still used and it is on the hot-path.

I guess you could reuse the same chunked-array strategy here. Or you could remove interfaces at all -- you have to count occurrences of tokens anyway.

Take a look at the patch I propose: no-interface.patch. In my opinion, code is much simpler and I believe it will show the same performance (or even slightly better, I guess).

@dkharms dkharms Oct 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've run quick benchmark and got slightly better results. Could you verify this against your dataset?

Query Type mean (ms) stddev (ms) p(50) (ms) p(95) (ms) p(99) (ms) iterations total
base comp diff base comp diff base comp diff base comp diff base comp diff base comp diff base comp diff
* | group by (service) | count
hot 110.84 104.62 -5.61% 12.97 13.52 +4.24% 109.88 106.42 -3.14% 130.10 125.40 -3.61% 142.65 134.39 -5.79% 90 90 0.00% 20061562 20061562 0.00%

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here hashmap is still used and it is on the hot-path.

Yes, countBySource is on the hot path. However, the ultimate plan is to wipe it our altogether. I'll explain.

Why do we need it? The author of the package is not with us anymore, so I tried to mentally reconstruct the thinking process behind this map.

countBySource serves two purposes:

  • it counts a number of unique sources which are part of the current agg for limiting
  • it counts a number of lids per each source

Why does it count a number of occurrences for each source? Apparently the answer is another map - tokensCache. Why do we need tokensCache? Because same source can be value-resolved multiple times. For time series agg a number of buckets for same sources can be literally anything, so same source can be resolved multiple times. This is why tokensCache is used for every source which has count of occurrences >= 2.

So, the whole point of of chunked array is that it's source-ordered (perf boost is extra, I'd be happy if chunked array had same perf as map). If it's source ordered, then I don't need tokensCache - I can just iterate all buckets in source-order. Hense we don't need a sort+prefetch in Aggregate.

The tricks it to do this for all single source aggregators (if possible). If tokensCache is not needed, then countBySource can be literally a bool array. Calculate the legnth of bool array can be slow, but I'm sure limits will be ok if I calc the length once-per-batch.

The tricky part is to do all of this for TwoSourceAggregator. It will be way harder to do source-ordered collections (though possible, I haven't thought about this). So, my plan is to create another iterator - something like TokenCachingSourcedNodeIterator which can wrap a SourcedNodeIterator. It can be used in TwoSourceAggregator. It will have tokensCache and countBySource inside. prefetch+sort can be pushed up to two source aggregator for now, untill we start working on two source agg.

So, this is the plan for now. I'm not sure if everything works as I expect, but looks sane to me. This is why I don't touch countBySource now - the simple reason is I'm trying to delete it altogether.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

countBySource serves two purposes

Yep, this is correct.

then countBySource can be literally a bool array

Wait, but how in this case you would know how many documents contain token $t$? You'll still have to count occurences of documents somehow, therefore some variant of countBySource is necessary. Am I missing something?

In my patch countBySource is the only array that is used for counting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, in my plan counter (chunked array) stays here, same as now, in plainSourceCounter. countBySource will eventually be downgraded to bool array/bitset, so there is no counting in aggregations which do not need it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why you need to store two almost identical arrays instead of one? Only one countBySource in SourcedNodeIterator is necessary.

aggMap := make(map[seq.AggBin]*seq.SamplesContainer, n.group.UniqueSources())
// tsSourceCounter is a counter used in time series count aggregations.
type tsSourceCounter struct {
counts map[AggBin[int]]int64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you could apply the same chunked-array strategy here as well. As a follow-up, of course.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right. I'm working on it right now. Techincally, it's easy to have an array of arrays (chunked array of arrays to be exact, i.e. 3D array). The entry can be a small struct of MID+count.

However, it's gonna suck when interval is low as 1ms and it's a low cardinality agg. So, I'm trying to come up with some sort of hybrid structure where we use 3D array when number of buckets is low (it's gonna be true in 99% scenarios) and spill to hash-map when there are too many buckets.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hybrid structure where we use 3D array

Interesting. 3D-array implies three pointer dereferences -- I wonder how cache locality in this case will affect performance.

@cheb0
cheb0 force-pushed the 0-single-source-count-agg-batched branch from 1c8fc56 to 830b602 Compare October 7, 2026 11:17
@cheb0
cheb0 force-pushed the 0-single-source-count-agg-batched branch from 830b602 to 7d8acb7 Compare October 7, 2026 11:19
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🔴 Performance Degradation

Some benchmarks have degraded compared to the previous run.
Click on Show table button to see full list of degraded benchmarks.

Show table
Name Previous Current Ratio Verdict
AggDeep/size=10000-4 70af15 9162ca
0.00 B/op 3.00 B/op NaN 🔴
AggDeep/size=1000000-4 70af15 9162ca
0.00 B/op 31792.00 B/op NaN 🔴
AggWide/size=10000-4 70af15 9162ca
0.00 B/op 3.00 B/op NaN 🔴
AggWide/size=1000000-4 70af15 9162ca
842.00 B/op 34985.00 B/op 41.55 🔴

@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🔴 Performance Degradation

Some benchmarks have degraded compared to the previous run.
Click on Show table button to see full list of degraded benchmarks.

Show table
Name Previous Current Ratio Verdict
AggDeep/size=10000-4 70af15 9162ca
0.00 B/op 3.00 B/op NaN 🔴
AggDeep/size=1000000-4 70af15 9162ca
0.00 B/op 31053.00 B/op NaN 🔴
AggWide/size=10000-4 70af15 9162ca
0.00 B/op 3.00 B/op NaN 🔴
AggWide/size=1000000-4 70af15 9162ca
842.00 B/op 35766.00 B/op 42.48 🔴

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants