metrale_telemetry/
instrument.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Lock-free instruments: counters, gauges and power-of-two
4//! histograms. All are `const`-constructible, so a whole instrument set can
5//! live in a `static` without a lazy initialiser.
6//!
7//! Every instrument is a set of independent relaxed atomics, so a reader may
8//! see a histogram's `count` and buckets disagree by the observations in
9//! flight. Values that must be read together (a device sample) go through
10//! [`crate::seqcell::SeqCell`] instead. Recording is not level-gated here;
11//! [`crate::hub::Telemetry`] does that.
12//!
13//! Owner: telemetry.
14//! Invariants: recording never allocates.
15
16use std::sync::atomic::{AtomicU64, Ordering};
17
18/// 2026-09-26: Monotonic count.
19#[derive(Debug, Default)]
20pub struct Counter(AtomicU64);
21
22impl Counter {
23    pub const fn new() -> Self {
24        Self(AtomicU64::new(0))
25    }
26    #[inline]
27    pub fn add(&self, n: u64) {
28        self.0.fetch_add(n, Ordering::Relaxed);
29    }
30    pub fn get(&self) -> u64 {
31        self.0.load(Ordering::Relaxed)
32    }
33}
34
35/// 2026-09-26: Last-value integer gauge.
36#[derive(Debug, Default)]
37pub struct Gauge(AtomicU64);
38
39impl Gauge {
40    pub const fn new() -> Self {
41        Self(AtomicU64::new(0))
42    }
43    #[inline]
44    pub fn set(&self, v: u64) {
45        self.0.store(v, Ordering::Relaxed);
46    }
47    pub fn get(&self) -> u64 {
48        self.0.load(Ordering::Relaxed)
49    }
50}
51
52/// 2026-09-26: Last-value float gauge: the f64's bits in one atomic word.
53/// `None` is stored as NaN, so an unmeasured ratio reads as `None`, not 0.
54#[derive(Debug)]
55pub struct GaugeF64(AtomicU64);
56
57const UNSET: u64 = f64::NAN.to_bits();
58
59impl Default for GaugeF64 {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl GaugeF64 {
66    pub const fn new() -> Self {
67        Self(AtomicU64::new(UNSET))
68    }
69    #[inline]
70    pub fn set(&self, v: Option<f64>) {
71        self.0
72            .store(v.map_or(UNSET, f64::to_bits), Ordering::Relaxed);
73    }
74    pub fn get(&self) -> Option<f64> {
75        let v = f64::from_bits(self.0.load(Ordering::Relaxed));
76        (!v.is_nan()).then_some(v)
77    }
78}
79
80/// 2026-09-26: A histogram with `N` finite power-of-two buckets plus `+Inf`.
81///
82/// A value lands in the first bucket `i` whose bound `2^(SHIFT + i)` covers
83/// it, or in the overflow slot. Placing it is a leading-zeros count.
84#[derive(Debug)]
85pub struct Histogram<const N: usize, const SHIFT: u32> {
86    buckets: [AtomicU64; N],
87    overflow: AtomicU64,
88    sum: AtomicU64,
89    count: AtomicU64,
90}
91
92impl<const N: usize, const SHIFT: u32> Default for Histogram<N, SHIFT> {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// 2026-09-26: A copy of a histogram's state, cumulative: `cumulative[i]`
99/// counts values `<= upper_bounds[i]`.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct HistogramSnapshot {
102    pub upper_bounds: Vec<u64>,
103    pub cumulative: Vec<u64>,
104    pub sum: u64,
105    pub count: u64,
106}
107
108impl<const N: usize, const SHIFT: u32> Histogram<N, SHIFT> {
109    pub const fn new() -> Self {
110        Self {
111            buckets: [const { AtomicU64::new(0) }; N],
112            overflow: AtomicU64::new(0),
113            sum: AtomicU64::new(0),
114            count: AtomicU64::new(0),
115        }
116    }
117
118    /// 2026-09-26: The bucket index for `v`, `N` meaning `+Inf`.
119    pub const fn bucket_of(v: u64) -> usize {
120        if v <= (1u64 << SHIFT) {
121            return 0;
122        }
123        // 2026-09-26: ceil(log2(v)); here v > 2^SHIFT >= 1.
124        let ceil_log2 = 64 - (v - 1).leading_zeros();
125        let i = (ceil_log2 - SHIFT) as usize;
126        if i < N { i } else { N }
127    }
128
129    pub const fn upper_bound(i: usize) -> u64 {
130        1u64 << (SHIFT + i as u32)
131    }
132
133    #[inline]
134    pub fn observe(&self, v: u64) {
135        let i = Self::bucket_of(v);
136        let slot = if i < N {
137            &self.buckets[i]
138        } else {
139            &self.overflow
140        };
141        slot.fetch_add(1, Ordering::Relaxed);
142        self.sum.fetch_add(v, Ordering::Relaxed);
143        self.count.fetch_add(1, Ordering::Relaxed);
144    }
145
146    pub fn count(&self) -> u64 {
147        self.count.load(Ordering::Relaxed)
148    }
149
150    pub fn snapshot(&self) -> HistogramSnapshot {
151        let mut running = 0;
152        let cumulative = self
153            .buckets
154            .iter()
155            .map(|b| {
156                running += b.load(Ordering::Relaxed);
157                running
158            })
159            .collect();
160        HistogramSnapshot {
161            upper_bounds: (0..N).map(Self::upper_bound).collect(),
162            cumulative,
163            sum: self.sum.load(Ordering::Relaxed),
164            count: self.count.load(Ordering::Relaxed),
165        }
166    }
167}
168
169/// 2026-09-26: Nanosecond durations, bounds 1.024 µs to ~68.7 s.
170pub type NanosHistogram = Histogram<27, 10>;
171/// 2026-09-26: Small counts (batch rows, prefilling sequences), bounds 1 to 1024.
172pub type CountHistogram = Histogram<11, 0>;
173/// 2026-09-26: Millijoules, bounds 1 mJ to ~16.8 kJ.
174pub type MillijouleHistogram = Histogram<25, 0>;
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    type H = Histogram<4, 2>;
181
182    #[test]
183    fn values_land_in_the_first_bucket_whose_bound_covers_them() {
184        // 2026-09-26: Bounds: 4, 8, 16, 32, then +Inf.
185        assert_eq!(H::bucket_of(0), 0);
186        assert_eq!(H::bucket_of(4), 0);
187        assert_eq!(H::bucket_of(5), 1);
188        assert_eq!(H::bucket_of(8), 1);
189        assert_eq!(H::bucket_of(9), 2);
190        assert_eq!(H::bucket_of(32), 3);
191        assert_eq!(H::bucket_of(33), 4, "past the last bound is +Inf");
192        assert_eq!(H::bucket_of(u64::MAX), 4);
193    }
194
195    #[test]
196    fn the_snapshot_is_cumulative_and_keeps_sum_and_count() {
197        let h = H::new();
198        for v in [1, 6, 7, 30, 1000] {
199            h.observe(v);
200        }
201        let s = h.snapshot();
202        assert_eq!(s.upper_bounds, vec![4, 8, 16, 32]);
203        assert_eq!(s.cumulative, vec![1, 3, 3, 4]);
204        assert_eq!(s.count, 5, "the +Inf observation counts in the total");
205        assert_eq!(s.sum, 1044);
206    }
207
208    #[test]
209    fn an_unset_float_gauge_is_none_not_zero() {
210        let g = GaugeF64::new();
211        assert_eq!(g.get(), None);
212        g.set(Some(0.25));
213        assert_eq!(g.get(), Some(0.25));
214        g.set(None);
215        assert_eq!(g.get(), None);
216    }
217
218    #[test]
219    fn the_nanos_histogram_spans_a_microsecond_to_a_minute() {
220        assert_eq!(NanosHistogram::upper_bound(0), 1024);
221        assert!(NanosHistogram::upper_bound(26) > 60_000_000_000);
222    }
223}