Skip to content

Commit 01c6f3d

Browse files
committed
fix(metrics): bind() at cardinality limit binds directly to overflow
The previous Fallback path re-routed every bound.add() through the unbound measure() path, creating a ~25x perf cliff at the cardinality limit (~50ns vs ~1.8ns) and letting attribution drift across delta eviction cycles as space freed and refilled. ValueMap::bind() now looks up or lazily creates the overflow TrackerEntry at the limit and returns a direct handle to it. The bound handle writes directly to overflow via a single atomic update for its lifetime — perf parity with a normal bind. To recover after delta eviction frees space, drop the handle and rebind. Spec-aligned with open-telemetry/opentelemetry-specification#5050: overflow data lands in the otel.metric.overflow=true bucket (MUST) and per-call attribute lookup is bypassed (SHOULD). Bench (Apple M4 Max): Counter_Bound_AtOverflow_Delta: 1.82 ns Histogram_Bound_AtOverflow_Delta: 6.58 ns
1 parent 6ebbe5a commit 01c6f3d

6 files changed

Lines changed: 284 additions & 115 deletions

File tree

opentelemetry-sdk/CHANGELOG.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
attribute set always aggregate into the same data point, including the empty
1515
attribute set. Bound entries are never evicted during delta collection while
1616
a handle exists — idle cycles produce no export but the tracker persists. If
17-
`bind()` is called at the cardinality limit, the handle transparently falls
18-
back to the unbound measurement path. Gated behind the
19-
`experimental_metrics_bound_instruments` feature flag. Benchmarks show ~28x
20-
speedup for counter operations and ~9x for histograms.
17+
`bind()` is called at the cardinality limit, the handle binds directly to
18+
the overflow tracker — its writes stay on the same direct (no-lookup) hot
19+
path and consistently land in the `otel.metric.overflow=true` bucket for
20+
the lifetime of the handle. To recover a bound handle after delta collection
21+
frees space, drop the existing handle and call `bind()` again. Gated behind
22+
the `experimental_metrics_bound_instruments` feature flag. Benchmarks show
23+
~28x speedup for counter operations and ~9x for histograms.
2124
- Delta metrics collection now uses in-place eviction instead of draining the
2225
HashMap on every collect cycle. Stale attribute sets that received no measurements
2326
since the last collection are evicted. Note: recovery from cardinality overflow

opentelemetry-sdk/benches/bound_instruments.rs

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
use criterion::{criterion_group, criterion_main, Criterion};
22
use opentelemetry::{metrics::MeterProvider as _, Key, KeyValue};
3-
use opentelemetry_sdk::metrics::{ManualReader, SdkMeterProvider, Stream, Temporality};
3+
use opentelemetry_sdk::metrics::{Instrument, ManualReader, SdkMeterProvider, Stream, Temporality};
44

55
// Run this benchmark with:
66
// cargo bench --bench bound_instruments --features metrics,experimental_metrics_custom_reader,experimental_metrics_bound_instruments,spec_unstable_metrics_views
77
//
88
// Apple M4 Max, 16 cores (12 performance + 4 efficiency), macOS 15.4
99
//
1010
// Results (3 attributes: method, status, path):
11-
// Counter_Unbound_Delta time: [53.20 ns]
12-
// Counter_Bound_Delta time: [1.87 ns] ~28x faster
13-
// Counter_Bound_With_View_Delta time: [~1.9 ns] view filter applied at bind, not on hot path
14-
// Histogram_Unbound_Delta time: [58.58 ns]
15-
// Histogram_Bound_Delta time: [6.57 ns] ~8.9x faster
16-
// Counter_Bound_Multithread/2 time: [22.19 µs] (100 adds/thread)
17-
// Counter_Bound_Multithread/4 time: [35.32 µs] (100 adds/thread)
18-
// Counter_Bound_Multithread/8 time: [66.49 µs] (100 adds/thread)
11+
// Counter_Unbound_Delta time: [50.20 ns]
12+
// Counter_Bound_Delta time: [ 1.80 ns] ~28x faster
13+
// Counter_Bound_With_View_Delta time: [ 1.82 ns] view filter applied at bind, not on hot path
14+
// Counter_Bound_AtOverflow_Delta time: [ 1.82 ns] bind() at cardinality limit binds directly to the overflow
15+
// tracker — perf parity with a normal bind, no per-call resolution
16+
// Histogram_Unbound_Delta time: [58.64 ns]
17+
// Histogram_Bound_Delta time: [ 6.50 ns] ~9.0x faster
18+
// Histogram_Bound_AtOverflow_Delta time: [ 6.58 ns] perf parity with a normal bind
19+
// Counter_Bound_Multithread/2 time: [21.59 µs] (100 adds/thread)
20+
// Counter_Bound_Multithread/4 time: [37.21 µs] (100 adds/thread)
21+
// Counter_Bound_Multithread/8 time: [71.70 µs] (100 adds/thread)
1922
//
2023
// Note: criterion does not fail CI on regression by itself. These numbers are
2124
// reference values for human review; use `cargo criterion --baseline` locally
@@ -90,6 +93,40 @@ fn bench_bound_instruments(c: &mut Criterion) {
9093
});
9194
}
9295

96+
// Counter: Bound at overflow — confirms that binding when the cardinality
97+
// limit is exhausted yields the same hot-path performance as a normal bind
98+
// (writes go directly to the overflow tracker, no per-call resolution).
99+
{
100+
let cardinality_limit = 4;
101+
let view = move |i: &Instrument| {
102+
if i.name() == "bound_at_overflow" {
103+
Stream::builder()
104+
.with_cardinality_limit(cardinality_limit)
105+
.build()
106+
.ok()
107+
} else {
108+
None
109+
}
110+
};
111+
let reader = ManualReader::builder()
112+
.with_temporality(Temporality::Delta)
113+
.build();
114+
let provider = SdkMeterProvider::builder()
115+
.with_reader(reader)
116+
.with_view(view)
117+
.build();
118+
let meter = provider.meter("bench");
119+
let counter = meter.u64_counter("bound_at_overflow").build();
120+
// Saturate cardinality with unbound calls so bind() lands in overflow.
121+
for i in 0..cardinality_limit {
122+
counter.add(1, &[KeyValue::new("filler", i as i64)]);
123+
}
124+
let bound = counter.bind(&attrs);
125+
group.bench_function("Counter_Bound_AtOverflow_Delta", |b| {
126+
b.iter(|| bound.add(1));
127+
});
128+
}
129+
93130
// Histogram: Unbound vs Bound (Delta)
94131
{
95132
let provider = create_provider(Temporality::Delta);
@@ -110,6 +147,37 @@ fn bench_bound_instruments(c: &mut Criterion) {
110147
});
111148
}
112149

150+
// Histogram: Bound at overflow — same property as the counter version.
151+
{
152+
let cardinality_limit = 4;
153+
let view = move |i: &Instrument| {
154+
if i.name() == "bound_hist_at_overflow" {
155+
Stream::builder()
156+
.with_cardinality_limit(cardinality_limit)
157+
.build()
158+
.ok()
159+
} else {
160+
None
161+
}
162+
};
163+
let reader = ManualReader::builder()
164+
.with_temporality(Temporality::Delta)
165+
.build();
166+
let provider = SdkMeterProvider::builder()
167+
.with_reader(reader)
168+
.with_view(view)
169+
.build();
170+
let meter = provider.meter("bench");
171+
let histogram = meter.f64_histogram("bound_hist_at_overflow").build();
172+
for i in 0..cardinality_limit {
173+
histogram.record(1.5, &[KeyValue::new("filler", i as i64)]);
174+
}
175+
let bound = histogram.bind(&attrs);
176+
group.bench_function("Histogram_Bound_AtOverflow_Delta", |b| {
177+
b.iter(|| bound.record(1.5));
178+
});
179+
}
180+
113181
// Multi-threaded bound counter
114182
for num_threads in [2, 4, 8] {
115183
let provider = create_provider(Temporality::Delta);

opentelemetry-sdk/src/metrics/internal/histogram.rs

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use opentelemetry::KeyValue;
1414
use super::aggregate::{AggregateTimeInitiator, AttributeSetFilter};
1515
use super::{Aggregator, ComputeAggregation, Measure, Number, ValueMap};
1616
#[cfg(feature = "experimental_metrics_bound_instruments")]
17-
use super::{BoundMeasure, TrackerEntry};
17+
use super::{BoundFallbackHandle, BoundMeasure, TrackerEntry};
1818

1919
impl<T> Aggregator for Mutex<Buckets<T>>
2020
where
@@ -72,48 +72,30 @@ impl<T: Number> Buckets<T> {
7272
}
7373
}
7474

75-
#[cfg(feature = "experimental_metrics_bound_instruments")]
76-
enum BoundHistogramInner<T: Number> {
77-
/// Fast path: dedicated tracker for this attribute set.
78-
Direct {
79-
tracker: Arc<TrackerEntry<Mutex<Buckets<T>>>>,
80-
bounds: Vec<f64>,
81-
},
82-
/// Overflow fallback: delegates to the unbound Measure::call() path.
83-
Fallback {
84-
measure: Arc<dyn Measure<T>>,
85-
attrs: Vec<KeyValue>,
86-
},
87-
}
88-
75+
/// Pre-bound histogram handle: writes go directly to a fixed `TrackerEntry`
76+
/// without per-call attribute lookup. The `tracker` is either a dedicated entry
77+
/// for the bound attribute set, or — if bind() hit the cardinality limit — the
78+
/// shared overflow tracker.
8979
#[cfg(feature = "experimental_metrics_bound_instruments")]
9080
struct BoundHistogramHandle<T: Number> {
91-
inner: BoundHistogramInner<T>,
81+
tracker: Arc<TrackerEntry<Mutex<Buckets<T>>>>,
82+
bounds: Vec<f64>,
9283
}
9384

9485
#[cfg(feature = "experimental_metrics_bound_instruments")]
9586
impl<T: Number> BoundMeasure<T> for BoundHistogramHandle<T> {
9687
fn call(&self, measurement: T) {
97-
match &self.inner {
98-
BoundHistogramInner::Direct { tracker, bounds } => {
99-
let f = measurement.into_float();
100-
let index = bounds.partition_point(|&x| x < f);
101-
tracker.aggregator.update((measurement, index));
102-
tracker.has_been_updated.store(true, Ordering::Release);
103-
}
104-
BoundHistogramInner::Fallback { measure, attrs } => {
105-
measure.call(measurement, attrs);
106-
}
107-
}
88+
let f = measurement.into_float();
89+
let index = self.bounds.partition_point(|&x| x < f);
90+
self.tracker.aggregator.update((measurement, index));
91+
self.tracker.has_been_updated.store(true, Ordering::Release);
10892
}
10993
}
11094

11195
#[cfg(feature = "experimental_metrics_bound_instruments")]
11296
impl<T: Number> Drop for BoundHistogramHandle<T> {
11397
fn drop(&mut self) {
114-
if let BoundHistogramInner::Direct { tracker, .. } = &self.inner {
115-
tracker.bound_count.fetch_sub(1, Ordering::Relaxed);
116-
}
98+
self.tracker.bound_count.fetch_sub(1, Ordering::Relaxed);
11799
}
118100
}
119101

@@ -291,17 +273,16 @@ where
291273
self.filter.apply(attrs, |filtered| {
292274
bound_attrs = filtered.to_vec();
293275
});
294-
let inner = match self.value_map.bind(&bound_attrs) {
295-
Some(tracker) => BoundHistogramInner::Direct {
276+
match self.value_map.bind(&bound_attrs) {
277+
Some(tracker) => Box::new(BoundHistogramHandle {
296278
tracker,
297279
bounds: self.bounds.clone(),
298-
},
299-
None => BoundHistogramInner::Fallback {
300-
measure: fallback,
301-
attrs: bound_attrs,
302-
},
303-
};
304-
Box::new(BoundHistogramHandle { inner })
280+
}),
281+
// Trackers RwLock is poisoned — extremely rare. Hand back a fallback
282+
// handle whose writes will silently drop (mirroring `measure()`'s
283+
// own poison handling) rather than panic on the user's hot path.
284+
None => Box::new(BoundFallbackHandle::new(fallback, bound_attrs)),
285+
}
305286
}
306287
}
307288

opentelemetry-sdk/src/metrics/internal/mod.rs

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,16 @@ where
198198
/// The caller can then call `tracker.aggregator.update()` directly, bypassing the
199199
/// full lookup path on subsequent measurements.
200200
///
201-
/// Returns `None` if the cardinality limit has been reached. The caller should
202-
/// fall back to the unbound `Measure::call()` path, which handles overflow
203-
/// correctly and enables automatic recovery when space opens up after delta
204-
/// collection evicts stale entries.
201+
/// When the cardinality limit has been reached, the returned tracker is the
202+
/// overflow tracker (the same one unbound `measure()` calls write to at
203+
/// overflow), preserving the bind() perf contract — every subsequent
204+
/// `bound.add()` call is a direct write, regardless of cardinality state.
205+
/// The handle remains permanently bound to overflow for its lifetime;
206+
/// to recover after space frees up, drop and re-bind.
207+
///
208+
/// Returns `None` only if the trackers RwLock is poisoned, in which case
209+
/// the caller should produce a noop bound handle so measurements are
210+
/// silently dropped rather than panicking on the user's hot path.
205211
#[cfg(feature = "experimental_metrics_bound_instruments")]
206212
fn bind(&self, attributes: &[KeyValue]) -> Option<Arc<TrackerEntry<A>>> {
207213
if attributes.is_empty() {
@@ -231,7 +237,7 @@ where
231237

232238
// Slow path: write lock, insert if missing.
233239
let Ok(mut trackers) = self.trackers.write() else {
234-
// Lock poisoned — return None so caller falls back to unbound path
240+
// Lock poisoned — caller will produce a noop bound handle.
235241
return None;
236242
};
237243

@@ -254,25 +260,27 @@ where
254260
self.count.fetch_add(1, Ordering::SeqCst);
255261
Some(new_tracker)
256262
} else {
257-
// Over cardinality limit — return None so the caller falls back to the
258-
// unbound Measure::call() path. This ensures:
259-
// 1. Overflow data is attributed correctly (same as unbound at overflow)
260-
// 2. Automatic recovery when delta collect evicts stale entries
261-
// 3. bound_count is not inflated on the overflow tracker
263+
// Over cardinality limit — bind directly to the overflow tracker so
264+
// the bound handle keeps its perf contract (no per-call lookup) and
265+
// its writes land predictably in the overflow bucket. This matches
266+
// the spec SHOULD that the SDK pre-resolve aggregator state at bind
267+
// time, and the spec MUST that bound recordings behave identically
268+
// to unbound recordings (which themselves route to overflow once
269+
// cardinality is exhausted). See open-telemetry/opentelemetry-specification#5050.
262270
//
263-
// TODO: If bind() is called during overflow, the handle remains in fallback
264-
// mode permanently even after delta collect frees space. Not an issue in
265-
// practice since bind() typically occurs at application startup before
266-
// cardinality fills up. Future options to revisit:
267-
// - Internally adjust bindings from overflow to normal during delta collect
268-
// - Exclude bind() from cardinality capping (users leveraging bind() know
269-
// their bound instruments always report properly and never overflow)
270-
// See: https://github.com/open-telemetry/opentelemetry-rust/pull/3392#discussion_r2860376315
271+
// The overflow tracker is created lazily here if it doesn't exist
272+
// yet — mirrors the lazy creation in `measure()` (line above where
273+
// overflow is inserted on first overflowing measurement).
274+
let overflow_tracker = trackers
275+
.entry(stream_overflow_attributes().clone())
276+
.or_insert_with(|| Arc::new(TrackerEntry::<A>::new(&self.config)))
277+
.clone();
278+
overflow_tracker.bound_count.fetch_add(1, Ordering::Relaxed);
271279
otel_debug!(
272280
name: "BoundInstrument.CardinalityOverflow",
273-
message = "bind() called at cardinality limit, falling back to unbound path"
281+
message = "bind() called at cardinality limit, attributing to overflow bucket"
274282
);
275-
None
283+
Some(overflow_tracker)
276284
}
277285
}
278286

opentelemetry-sdk/src/metrics/internal/sum.rs

Lines changed: 17 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use super::aggregate::{AggregateTimeInitiator, AttributeSetFilter};
1010
use super::{Aggregator, AtomicTracker, ComputeAggregation, Measure, Number};
1111
use super::{AtomicallyUpdate, ValueMap};
1212
#[cfg(feature = "experimental_metrics_bound_instruments")]
13-
use super::{BoundMeasure, TrackerEntry};
13+
use super::{BoundFallbackHandle, BoundMeasure, TrackerEntry};
1414

1515
struct Increment<T>
1616
where
@@ -43,48 +43,28 @@ where
4343
}
4444
}
4545

46-
#[cfg(feature = "experimental_metrics_bound_instruments")]
47-
enum BoundSumInner<T: Number> {
48-
/// Fast path: dedicated tracker for this attribute set.
49-
Direct {
50-
tracker: Arc<TrackerEntry<Increment<T>>>,
51-
},
52-
/// Overflow fallback: delegates to the unbound Measure::call() path.
53-
/// This happens when bind() is called at/over the cardinality limit.
54-
/// Using the unbound path ensures correct overflow attribution and
55-
/// automatic recovery when delta collect opens up space.
56-
Fallback {
57-
measure: Arc<dyn Measure<T>>,
58-
attrs: Vec<KeyValue>,
59-
},
60-
}
61-
46+
/// Pre-bound counter handle: writes go directly to a fixed `TrackerEntry` without
47+
/// per-call attribute lookup. The `tracker` is either a dedicated entry for the
48+
/// bound attribute set, or — if bind() hit the cardinality limit — the shared
49+
/// overflow tracker. Either way, `call()` is a single atomic increment and a
50+
/// release store; no map lookup, no lock acquisition.
6251
#[cfg(feature = "experimental_metrics_bound_instruments")]
6352
struct BoundSumHandle<T: Number> {
64-
inner: BoundSumInner<T>,
53+
tracker: Arc<TrackerEntry<Increment<T>>>,
6554
}
6655

6756
#[cfg(feature = "experimental_metrics_bound_instruments")]
6857
impl<T: Number> BoundMeasure<T> for BoundSumHandle<T> {
6958
fn call(&self, measurement: T) {
70-
match &self.inner {
71-
BoundSumInner::Direct { tracker } => {
72-
tracker.aggregator.update(measurement);
73-
tracker.has_been_updated.store(true, Ordering::Release);
74-
}
75-
BoundSumInner::Fallback { measure, attrs } => {
76-
measure.call(measurement, attrs);
77-
}
78-
}
59+
self.tracker.aggregator.update(measurement);
60+
self.tracker.has_been_updated.store(true, Ordering::Release);
7961
}
8062
}
8163

8264
#[cfg(feature = "experimental_metrics_bound_instruments")]
8365
impl<T: Number> Drop for BoundSumHandle<T> {
8466
fn drop(&mut self) {
85-
if let BoundSumInner::Direct { tracker } = &self.inner {
86-
tracker.bound_count.fetch_sub(1, Ordering::Relaxed);
87-
}
67+
self.tracker.bound_count.fetch_sub(1, Ordering::Relaxed);
8868
}
8969
}
9070

@@ -211,14 +191,13 @@ where
211191
self.filter.apply(attrs, |filtered| {
212192
bound_attrs = filtered.to_vec();
213193
});
214-
let inner = match self.value_map.bind(&bound_attrs) {
215-
Some(tracker) => BoundSumInner::Direct { tracker },
216-
None => BoundSumInner::Fallback {
217-
measure: fallback,
218-
attrs: bound_attrs,
219-
},
220-
};
221-
Box::new(BoundSumHandle { inner })
194+
match self.value_map.bind(&bound_attrs) {
195+
Some(tracker) => Box::new(BoundSumHandle { tracker }),
196+
// Trackers RwLock is poisoned — extremely rare. Hand back a fallback
197+
// handle whose writes will silently drop (mirroring `measure()`'s
198+
// own poison handling) rather than panic on the user's hot path.
199+
None => Box::new(BoundFallbackHandle::new(fallback, bound_attrs)),
200+
}
222201
}
223202
}
224203

0 commit comments

Comments
 (0)