proxima_core/
histogram.rs1use core::marker::PhantomData;
7use core::sync::atomic::{AtomicU64, Ordering};
8
9pub const MAX_BUCKETS: usize = 32;
10
11const EXP_BIAS_OFFSET: u64 = 1013;
13
14pub 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 #[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, 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 #[must_use]
75 pub const fn exponential_base(mut self, base: f64) -> Self {
76 self.base = base;
77 self
78 }
79
80 #[must_use]
82 pub const fn exponential_min(mut self, min: f64) -> Self {
83 self.min = min;
84 self
85 }
86
87 #[must_use]
89 pub fn count(&self) -> u64 {
90 self.count.load(Ordering::Relaxed)
91 }
92
93 #[must_use]
95 pub fn sum(&self) -> f64 {
96 f64::from_bits(self.sum_bits.load(Ordering::Relaxed))
97 }
98
99 #[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 #[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 fn slot_for_bits(bits: u64, bucket_count: usize) -> usize {
128 let exp_field = (bits >> 52) & 0x7FF;
129 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 self.sum_bits
140 .store((self.sum() + sum_f64).to_bits(), Ordering::Relaxed);
141 }
142}
143
144impl Histogram<f64> {
145 pub fn record(&self, value: f64) {
148 self.record_inner(value.abs().to_bits(), value);
149 }
150}
151
152impl Histogram<u64> {
153 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 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 #[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 assert_eq!(snap[10], 1);
188 assert_eq!(hist.count(), 1);
189 }
190
191 #[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 let expected = bucket_pick_linear(42.0, MAX_BUCKETS);
199 assert_eq!(snap[expected], 1);
200 assert_eq!(hist.count(), 1);
201 }
202
203 #[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 assert_eq!(snap[0], 1);
213 assert_eq!(snap[10], 1);
215 let last_slot = bucket_pick_linear(1_000_000.0, MAX_BUCKETS);
217 assert_eq!(snap[last_slot], 1);
218 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 #[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 #[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 #[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 #[test]
283 fn sizes_are_pinned() {
284 assert_eq!(core::mem::size_of::<Histogram<f64>>(), 344);
286 assert_eq!(core::mem::size_of::<Histogram<u64>>(), 344);
287 }
288}