Skip to main content

proxima_core/
histogram.rs

1//! Lock-free latency/size histogram with a branchless base-2 bucket pick.
2//!
3//! Gated on `target_has_atomic = "64"` at the crate root: the accumulators are
4//! `AtomicU64`, which a cortex-m target cannot back lock-free.
5
6use core::marker::PhantomData;
7use core::sync::atomic::{AtomicU64, Ordering};
8
9pub const MAX_BUCKETS: usize = 32;
10
11// offset so that exponent field 1013 maps to bucket 0: covers [2^-10, 2^-9)
12const EXP_BIAS_OFFSET: u64 = 1013;
13
14/// A histogram instrument with branchless base-2 exponential bucket pick.
15///
16/// 32 buckets span [2^-10, 2^22) — roughly [0.001, 4_000_000). Values
17/// outside that range saturate to bucket 0 or bucket 31. V is the observed
18/// value type: `f64` for latencies/sizes, `u64` for counts.
19///
20/// v1 baseline: single bucket slab per instrument; tag identity is accepted
21/// for API symmetry but not accumulated. C9 will shard by attr-set.
22pub struct Histogram<V> {
23    pub name: &'static str,
24    pub unit: &'static str,
25    pub description: &'static str,
26    base: f64,
27    min: f64,
28    bucket_count: usize,
29    buckets: [AtomicU64; MAX_BUCKETS],
30    count: AtomicU64,
31    sum_bits: AtomicU64,
32    _marker: PhantomData<V>,
33}
34
35impl<V> Histogram<V> {
36    #[must_use]
37    pub const fn new(name: &'static str) -> Self {
38        // clippy::declare_interior_mutable_const fires on `const ZERO: AtomicU64` because
39        // it's interior-mutable. allowed here because the const is used exclusively as the
40        // initializer for `[ZERO; MAX_BUCKETS]` — the array elements are distinct atomics,
41        // not aliased through the const item. const-constructible AtomicU64 from u64::default is
42        // necessary because `const fn` doesn't yet permit calling `AtomicU64::new(0)` inside
43        // an array literal without this binding.
44        #[allow(clippy::declare_interior_mutable_const)]
45        const ZERO: AtomicU64 = AtomicU64::new(0);
46        Self {
47            name,
48            unit: "",
49            description: "",
50            base: 2.0,
51            min: 9.765_625e-4, // 2^-10
52            bucket_count: MAX_BUCKETS,
53            buckets: [ZERO; MAX_BUCKETS],
54            count: AtomicU64::new(0),
55            sum_bits: AtomicU64::new(0),
56            _marker: PhantomData,
57        }
58    }
59
60    #[must_use]
61    pub const fn unit(mut self, unit: &'static str) -> Self {
62        self.unit = unit;
63        self
64    }
65
66    #[must_use]
67    pub const fn description(mut self, desc: &'static str) -> Self {
68        self.description = desc;
69        self
70    }
71
72    /// Set the exponential base. Only `2.0` is supported for the
73    /// branchless bucket pick; other bases will fall back to bucket 0.
74    #[must_use]
75    pub const fn exponential_base(mut self, base: f64) -> Self {
76        self.base = base;
77        self
78    }
79
80    /// Set the lower bound of bucket 0 (must be a power of 2 for the fast path).
81    #[must_use]
82    pub const fn exponential_min(mut self, min: f64) -> Self {
83        self.min = min;
84        self
85    }
86
87    /// Return the total observation count.
88    #[must_use]
89    pub fn count(&self) -> u64 {
90        self.count.load(Ordering::Relaxed)
91    }
92
93    /// Return the running sum as f64.
94    #[must_use]
95    pub fn sum(&self) -> f64 {
96        f64::from_bits(self.sum_bits.load(Ordering::Relaxed))
97    }
98
99    /// Return a snapshot of per-bucket counts.
100    #[must_use]
101    pub fn bucket_snapshot(&self) -> [u64; MAX_BUCKETS] {
102        let mut out = [0u64; MAX_BUCKETS];
103        for (index, bucket) in self.buckets.iter().enumerate() {
104            out[index] = bucket.load(Ordering::Relaxed);
105        }
106        out
107    }
108
109    /// Snapshot all state (count, sum_bits, bucket counts) and reset atomics to zero.
110    ///
111    /// Called by the drainer at snapshot time. Each atomic is swapped to 0 — any
112    /// observations racing the swap appear in the NEXT snapshot window (not lost).
113    #[must_use]
114    pub fn snapshot_and_reset(&self) -> (u64, u64, [u64; MAX_BUCKETS]) {
115        let count = self.count.swap(0, Ordering::Relaxed);
116        let sum_bits = self.sum_bits.swap(0, Ordering::Relaxed);
117        let mut bucket_counts = [0u64; MAX_BUCKETS];
118        for (index, bucket) in self.buckets.iter().enumerate() {
119            bucket_counts[index] = bucket.swap(0, Ordering::Relaxed);
120        }
121        (count, sum_bits, bucket_counts)
122    }
123
124    // Branchless IEEE 754 exponent-field slot for base-2 buckets.
125    // Extracts the 11-bit biased exponent, subtracts EXP_BIAS_OFFSET,
126    // and saturates to [0, bucket_count-1]. O(1) constant time.
127    fn slot_for_bits(bits: u64, bucket_count: usize) -> usize {
128        let exp_field = (bits >> 52) & 0x7FF;
129        // signed subtraction: subnormals/zero give exp_field=0 → saturates to 0
130        let raw = exp_field.saturating_sub(EXP_BIAS_OFFSET);
131        raw.min(bucket_count as u64 - 1) as usize
132    }
133
134    fn record_inner(&self, value_bits: u64, sum_f64: f64) {
135        let slot = Self::slot_for_bits(value_bits, self.bucket_count);
136        self.buckets[slot].fetch_add(1, Ordering::Relaxed);
137        self.count.fetch_add(1, Ordering::Relaxed);
138        // last-write-wins on sum: races are acceptable for a running aggregate
139        self.sum_bits
140            .store((self.sum() + sum_f64).to_bits(), Ordering::Relaxed);
141    }
142}
143
144impl Histogram<f64> {
145    /// Record an f64 observation. Negative values are treated as their
146    /// absolute magnitude for bucket assignment.
147    pub fn record(&self, value: f64) {
148        self.record_inner(value.abs().to_bits(), value);
149    }
150}
151
152impl Histogram<u64> {
153    /// Record a u64 observation (cast to f64 for bucket assignment).
154    pub fn record(&self, value: u64) {
155        let as_f64 = value as f64;
156        self.record_inner(as_f64.to_bits(), as_f64);
157    }
158}
159
160#[cfg(test)]
161#[allow(clippy::unwrap_used, clippy::expect_used)]
162mod tests {
163    extern crate std;
164
165    use std::sync::Arc;
166
167    use super::{Histogram, MAX_BUCKETS};
168
169    fn bucket_pick_linear(value: f64, bucket_count: usize) -> usize {
170        // reference: linear scan through base-2 powers from 2^-10
171        for index in 0..bucket_count {
172            let upper = 2.0f64.powi(index as i32 - 10 + 1);
173            if value < upper {
174                return index;
175            }
176        }
177        bucket_count - 1
178    }
179
180    // 1. happy: Histogram<f64>::record(1.0, &[]) increments bucket containing 1.0
181    #[test]
182    fn f64_record_increments_bucket_for_1() {
183        let hist: Histogram<f64> = Histogram::new("latency");
184        hist.record(1.0);
185        let snap = hist.bucket_snapshot();
186        // 1.0 is in bucket 10 (2^0..2^1)
187        assert_eq!(snap[10], 1);
188        assert_eq!(hist.count(), 1);
189    }
190
191    // 2. happy: Histogram<u64>::record(42, &[]) works (u64 path)
192    #[test]
193    fn u64_record_increments_correct_bucket() {
194        let hist: Histogram<u64> = Histogram::new("items");
195        hist.record(42);
196        let snap = hist.bucket_snapshot();
197        // 42 as f64 falls in bucket for [32, 64) = bucket 15
198        let expected = bucket_pick_linear(42.0, MAX_BUCKETS);
199        assert_eq!(snap[expected], 1);
200        assert_eq!(hist.count(), 1);
201    }
202
203    // 3. branchless bucket pick: boundary values land in correct slots
204    #[test]
205    fn boundary_values_land_in_expected_buckets() {
206        let hist: Histogram<f64> = Histogram::new("boundary");
207        hist.record(0.001);
208        hist.record(1.0);
209        hist.record(1_000_000.0);
210        let snap = hist.bucket_snapshot();
211        // 0.001 (< 2^-9) → bucket 0
212        assert_eq!(snap[0], 1);
213        // 1.0 = 2^0 → bucket 10
214        assert_eq!(snap[10], 1);
215        // 1_000_000.0 = ~2^19.9 → bucket 29
216        let last_slot = bucket_pick_linear(1_000_000.0, MAX_BUCKETS);
217        assert_eq!(snap[last_slot], 1);
218        // large overflow saturates to bucket 31
219        let big: Histogram<f64> = Histogram::new("big");
220        big.record(5_000_000.0);
221        let big_snap = big.bucket_snapshot();
222        assert_eq!(big_snap[MAX_BUCKETS - 1], 1);
223    }
224
225    // 4. correctness: branchless matches linear reference across a range of values
226    #[proxima::test]
227    #[case::sub_min(0.0001)]
228    #[case::at_min(0.001)]
229    #[case::small(0.01)]
230    #[case::tenth(0.1)]
231    #[case::half(0.5)]
232    #[case::one(1.0)]
233    #[case::two(2.0)]
234    #[case::ten(10.0)]
235    #[case::hundred(100.0)]
236    #[case::thousand(1_000.0)]
237    #[case::ten_thousand(10_000.0)]
238    #[case::hundred_thousand(100_000.0)]
239    #[case::million(1_000_000.0)]
240    #[case::over_max(5_000_000.0)]
241    async fn branchless_matches_linear_reference(#[case] value: f64) {
242        let bits = value.abs().to_bits();
243        let exp_field = (bits >> 52) & 0x7FF;
244        let raw = exp_field.saturating_sub(crate::histogram::EXP_BIAS_OFFSET);
245        let branchless_slot = raw.min(MAX_BUCKETS as u64 - 1) as usize;
246        let linear_slot = bucket_pick_linear(value, MAX_BUCKETS);
247        assert_eq!(branchless_slot, linear_slot, "mismatch for value={value}");
248    }
249
250    // 5. concurrent: 8 threads each calling record → total count = 8 × N
251    #[test]
252    fn concurrent_record_total_count_matches() {
253        let hist = Arc::new(Histogram::<f64>::new("concurrent"));
254        let threads: std::vec::Vec<_> = (0..8)
255            .map(|_| {
256                let shared = Arc::clone(&hist);
257                std::thread::spawn(move || {
258                    for _ in 0..100 {
259                        shared.record(1.0);
260                    }
261                })
262            })
263            .collect();
264        for handle in threads {
265            handle.join().expect("thread panicked");
266        }
267        assert_eq!(hist.count(), 800);
268    }
269
270    // 6. builder fields are carried through const chain
271    #[test]
272    fn builder_fields_carried() {
273        let hist: Histogram<f64> = Histogram::new("req_duration")
274            .unit("ms")
275            .description("request latency");
276        assert_eq!(hist.name, "req_duration");
277        assert_eq!(hist.unit, "ms");
278        assert_eq!(hist.description, "request latency");
279    }
280
281    // 9. size assertions
282    #[test]
283    fn sizes_are_pinned() {
284        // 3×&str(16) + base(8) + min(8) + bucket_count(8) + buckets(256) + count(8) + sum(8) = 344
285        assert_eq!(core::mem::size_of::<Histogram<f64>>(), 344);
286        assert_eq!(core::mem::size_of::<Histogram<u64>>(), 344);
287    }
288}