Skip to content

Use sync.Map for exponential histogram aggregations - #8077

Draft
dashpole wants to merge 17 commits into
open-telemetry:mainfrom
dashpole:exphist_syncmap
Draft

Use sync.Map for exponential histogram aggregations#8077
dashpole wants to merge 17 commits into
open-telemetry:mainfrom
dashpole:exphist_syncmap

Conversation

@dashpole

@dashpole dashpole commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Part of #7796

This applies the same approach as I did for fixed-bucket histograms (#7474) to exponential histograms.

Changes

  • Double-Buffered Delta Aggregation:
    • Old: Used a single Go map protected by a sync.Mutex. Collection blocked writers while it iterated over the map and cleared it.
    • New: Uses a [2]limitedSyncMap (a specialized sync.Map with size limits). Writers record to a "hot" map, while collect reads from a "cold" map. A custom hotColdWaitGroup orchestrates wait-free swapping of these maps, ensuring collection does not block active writers.
  • Double-Buffered Cumulative Aggregation:
    • Old: Similar to Delta, used a single map with a global mutex.
    • New: To avoid map churn for cumulative metrics, it uses a single limitedSyncMap mapping to a cumulativePoint struct. This struct contains two active data points (hot and cold) and one persistent cumulative point. Writers use the hot point, and collect swaps them and merges the cold point into the cumulative point.
  • Fine-Grained Locking:
    • Old: A single mutex locked the entire aggregator for every measurement (measure) and collection (collect).
    • New: Removed the global lock. sync.Map handles concurrent lookups, and a mutex is only acquired at the individual data point level (expoHistogramDataPoint) when recording a value that requires bucket expansion or downscaling.

This does not make the buckets concurrent-safe. That will be done in subsequent PRs.

This does not try to prevent concurrent underflow when <=2 maxSize is used. We will just keep the lock around buckets for that case. It will have worse performance, but that's OK.

@dashpole dashpole added the Skip Changelog PRs that do not require a CHANGELOG.md entry label Mar 19, 2026
@codecov

codecov Bot commented Mar 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.44118% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.0%. Comparing base (ec606c3) to head (8057bcb).

Files with missing lines Patch % Lines
...metric/internal/aggregate/exponential_histogram.go 89.7% 17 Missing and 9 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##            main   #8077     +/-   ##
=======================================
- Coverage   84.0%   84.0%   -0.1%     
=======================================
  Files        329     329             
  Lines      26068   26219    +151     
=======================================
+ Hits       21918   22039    +121     
- Misses      3769    3787     +18     
- Partials     381     393     +12     
Files with missing lines Coverage Δ
sdk/metric/internal/aggregate/aggregate.go 100.0% <100.0%> (ø)
sdk/metric/internal/aggregate/atomic.go 92.5% <100.0%> (+0.3%) ⬆️
...metric/internal/aggregate/exponential_histogram.go 92.2% <89.7%> (-7.8%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dashpole

dashpole commented Apr 3, 2026

Copy link
Copy Markdown
Contributor Author

One part if this that is challenging to resolve is dealing with underflow for cumulative metrics. The current design mirrors how the histogram implementation works: Collection swaps hot and cold, reads the cold, and then merges the cold back into the hot point.

The issue comes during the merge process. It is possible that an observation made to the hot point should underflow, but we don't find that out until we try to merge the cold point into the hot one. For example:

attrs := attribute.NewSet()
maxSize := 2
h := newExpoHistogram(maxsize, ...)
h.measure(ctx, math.MaxFloat64, attrs, ...)
go h.collect(...)
h.measure(ctx, math.SmallestNonzeroFloat64, attrs)
// assume collect() finishes after measure, and tries to merge
// an exp histogram with math.MaxFloat64 into an exp histogram
// with math.SmallestNonzeroFloat64. This will underflow, but we
// can't remove the underflowed measurement after it has been
// aggregated.

This is an extremely rare case: Underflow is only possible with maxSize <= 2, and when making measurements where one is 2^1024 times greater than the other.

Some options i've come up with to deal with it:

  1. Fix it properly, but with significant complexity:
    1. Add a separate tracker that uses three atomic bits to track which scale -10 buckets have been seen to drop the measurement that underflows. This will probably have a small performance cost as well.
    2. "Pre-scale" buckets before swapping to make underflow impossible when merging cold back into hot. This is quite a bit more complex.
  2. Best-effort removal of underflowed measurements during the merge process:
    1. Remove the underflowed bucket counts, and lower the overall count by the same amount. Lower the sum proportional to the count to keep the average the same.
    2. Put the smallest underflowed measurements into the zero bucket. Raise the zero threshold, and move the smallest underflowed bucket counts to the zero count. In the worst case, the zero_threshold would be raised to 1.0, but the remaining range would fit into a single scale -10 bucket.

I'm planning to implement the proper fix (option 1.i), but I wanted to document this in-case it comes up later. Option 2.i is also appealing given how extremely rare this should be in-practice.

@dashpole
dashpole force-pushed the exphist_syncmap branch 3 times, most recently from fa32a82 to 906aa6e Compare April 6, 2026 19:59
MrAlias added a commit that referenced this pull request Apr 8, 2026
Some small testing improvements forked from
#8077.

This also fixes a flake where the order in which sums are added can
change the resulting sum. Use assertSumEqual to handle this similar to
other places in the test.

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>
@dashpole

Copy link
Copy Markdown
Contributor Author

I had an idea for how to solve the underflow: Just don't optimize the locking for exponential histograms of size 1 or 2! Use a standard lock around everything. Give that is an odd case, we don't need to optimize for amazing performance there.

@dashpole
dashpole force-pushed the exphist_syncmap branch 5 times, most recently from e387744 to 7a6afcc Compare May 27, 2026 22:01
@dashpole
dashpole marked this pull request as ready for review May 28, 2026 00:43
@dashpole

Copy link
Copy Markdown
Contributor Author

Finally cleaned this up. Ready for review, but no rush.

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.

Pull request overview

This PR refactors the exponential histogram aggregators in the metric SDK to reduce contention by replacing a single mutex-protected map with sync.Map-based structures and hot/cold swapping, aligning the implementation approach with the earlier fixed-bucket histogram optimization work.

Changes:

  • Replaces delta exponential histogram aggregation’s single locked map with a double-buffered hot/cold [2]limitedSyncMap coordinated by hotColdWaitGroup.
  • Introduces a new cumulative exponential histogram aggregator that keeps a stable limitedSyncMap of series keys while swapping per-series hot/cold delta points and merging into a persistent cumulative point.
  • Adds/updates tests to account for the new aggregator types and includes coverage for scale-underflow behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
sdk/metric/internal/aggregate/exponential_histogram.go Reworks delta + cumulative exponential histogram implementations to use limitedSyncMap and hot/cold synchronization; adds per-point merge and bucket merge helpers.
sdk/metric/internal/aggregate/exponential_histogram_test.go Updates tests for new constructors/collection paths and adds underflow test coverage for both delta and cumulative variants.
sdk/metric/internal/aggregate/aggregate.go Updates the Builder to construct delta vs cumulative exponential histogram aggregators explicitly and wire both to collect.

Comment thread sdk/metric/internal/aggregate/exponential_histogram.go
Comment thread sdk/metric/internal/aggregate/exponential_histogram.go Outdated
Comment thread sdk/metric/internal/aggregate/exponential_histogram.go Outdated
dashpole added a commit to dashpole/opentelemetry-go that referenced this pull request Jul 13, 2026
@MrAlias MrAlias added this to the v1.46.0 milestone Jul 23, 2026
@MrAlias MrAlias mentioned this pull request Jul 23, 2026
42 tasks

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

The double-buffered design is headed in the right direction. There is one cumulative exemplar collection boundary to tighten up before this lands.

Comment thread sdk/metric/internal/aggregate/exponential_histogram.go Outdated
@dashpole
dashpole force-pushed the exphist_syncmap branch 2 times, most recently from 80403e9 to b65a333 Compare July 31, 2026 20:19
dashpole and others added 15 commits August 7, 2026 17:07
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@dashpole

dashpole commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Converting to a draft until #8711 is resolved.

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

Labels

Skip Changelog PRs that do not require a CHANGELOG.md entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants