-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Use sync.Map and atomics for fixed bucket histograms #7474
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Use sync.Map and atomics for fixed bucket histograms #7474
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #7474 +/- ##
======================================
Coverage 86.1% 86.1%
======================================
Files 296 296
Lines 21594 21697 +103
======================================
+ Hits 18601 18693 +92
- Misses 2620 2631 +11
Partials 373 373
🚀 New features to boost your workflow:
|
ac02811 to
389d0bd
Compare
34d7502 to
f0b28ca
Compare
3e89e36 to
45effea
Compare
45effea to
b18ff3d
Compare
bwplotka
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to help a bit with review of this.
Generally looks good, just small things, but also I'm not a maintainer and a bit new to the codebase (I maintain Prometheus client_golang SDK though).
Great work, great to see amazing results! Do you mid benchmarking with allocs too?
| assert.Equal(t, int64(15), aSum.load()) | ||
| } | ||
|
|
||
| func BenchmarkAtomicCounter(b *testing.B) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You added a benchmark but I didn't see any results posted on PR or anywhere.
Is this useful benchmark then? Or there is some policy to add benchmarks for all low-level things just in case? (reminds me of YAGNI)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I see tha rationales #7474 (comment)
Tests are great.
Just 2c, but adding benchmarks without ever planning to use it (realistically) might is same as adding a dead code. They could be added when we want to execute and measure. Is it used anywhere now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We run benchmarks as part of CI, and also do a diff on push: https://github.com/open-telemetry/opentelemetry-go/blob/main/.github/workflows/benchmark.yml. Example: https://github.com/open-telemetry/opentelemetry-go/actions/runs/18314631364 from my PR for improving sum measure performance.
So if someone did make a change, we would at least be able to tell if it made performance significantly worse afterwards...
| b.count++ | ||
| func (b *histogramPointCounters[N]) loadCountsInto(into *[]uint64) uint64 { | ||
| // TODO (#3047): Making copies for bounds and counts incurs a large | ||
| // memory allocation footprint. Alternatives should be explored. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also not sure if this comment is useful. You could always invest more time to improve memory. Here is not that obvious way - you do all you can to only allocate if you need to resize. Maybe it's good enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are no allocs before or after. Added to the PR description.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are allocs for Collect. I can try to publish the collect benchmarks as well, but I suspect those get worse.
| b.counts[idx]++ | ||
| b.count++ | ||
| func (b *histogramPointCounters[N]) loadCountsInto(into *[]uint64) uint64 { | ||
| // TODO (#3047): Making copies for bounds and counts incurs a large |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // TODO (#3047): Making copies for bounds and counts incurs a large | |
| // TODO (#3047): Making copies for counts incurs a large |
Only for counts?
| // and resets the state of this set of counters. This is used by | ||
| // hotColdHistogramPoint to ensure that the cumulative counters continue to | ||
| // accumulate after being read. | ||
| func (b *histogramPointCounters[N]) mergeIntoAndReset( // nolint:revive // Intentional internal control flag |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What it is complaining for? What control flag means?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
revive doesn't like boolean arguments to functions. It considers them "control flags". See https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#flag-parameter. I'm not sure I agree that they should always be avoided, as it would cause a lot of code duplication...
| // | ||
| // Then, | ||
| // | ||
| // buckets = (-∞, 0], (0, 5.0], (5.0, 10.0], (10.0, +∞) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // buckets = (-∞, 0], (0, 5.0], (5.0, 10.0], (10.0, +∞) | |
| // counts = (-∞, 0], (0, 5.0], (5.0, 10.0], (10.0, +∞) |
Perhaps? To match the code
| // newCumulativeHistogram returns a histogram that accumulates measurements | ||
| // into a histogram data structure. It is never reset. | ||
| func newCumulativeHistogram[N int64 | float64]( | ||
| boundaries []float64, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How often (if ever) histograms change bucketing? Never during runtime?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
never during runtime.
| // | ||
| // Then, | ||
| // | ||
| // buckets = (-∞, 0], (0, 5.0], (5.0, 10.0], (10.0, +∞) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // buckets = (-∞, 0], (0, 5.0], (5.0, 10.0], (10.0, +∞) | |
| // count = (-∞, 0], (0, 5.0], (5.0, 10.0], (10.0, +∞) |
| // of s.bounds. This aligns with the histogramPoint in that the length of histogramPoint | ||
| // is len(s.bounds)+1, with the last bucket representing: | ||
| // (s.bounds[len(s.bounds)-1], +∞). | ||
| idx := sort.SearchFloat64s(s.bounds, float64(value)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you have to do it inside lock? s.bounds never change, right?
Yeah, zero allocs on the Measure() path before and after, so I didn't post it. |

Implement a lockless histogram using atomics, and use a sync.Map for attribute access. This improves performance by ~2x.
The design is very similar to #7427, but with one additional change to make the histogram data point itself atomic:
Parallel benchmarks:
zero memory allocations before and after this change. Omitted for brevity