1use std::collections::HashMap;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, OnceLock, RwLock};
38
39pub const HISTOGRAM_BUCKETS: usize = 30;
41
42pub struct LatencyHistogram {
50 buckets: [AtomicU64; HISTOGRAM_BUCKETS],
51 overflow: AtomicU64, sum_us: AtomicU64,
53 count: AtomicU64,
54 max_us: AtomicU64,
55}
56
57impl Default for LatencyHistogram {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl LatencyHistogram {
64 #[must_use]
65 pub fn new() -> Self {
66 Self {
68 buckets: std::array::from_fn(|_| AtomicU64::new(0)),
69 overflow: AtomicU64::new(0),
70 sum_us: AtomicU64::new(0),
71 count: AtomicU64::new(0),
72 max_us: AtomicU64::new(0),
73 }
74 }
75
76 pub fn record_us(&self, us: u64) {
78 let bucket = bucket_for(us);
79 if bucket >= HISTOGRAM_BUCKETS {
80 self.overflow.fetch_add(1, Ordering::Relaxed);
81 } else {
82 self.buckets[bucket].fetch_add(1, Ordering::Relaxed);
83 }
84 self.sum_us.fetch_add(us, Ordering::Relaxed);
85 self.count.fetch_add(1, Ordering::Relaxed);
86 let mut cur = self.max_us.load(Ordering::Relaxed);
88 while us > cur {
89 match self.max_us.compare_exchange_weak(cur, us, Ordering::Relaxed, Ordering::Relaxed) {
90 Ok(_) => break,
91 Err(observed) => cur = observed,
92 }
93 }
94 }
95
96 #[must_use]
98 pub fn snapshot(&self) -> HistogramSnapshot {
99 let mut buckets = [0u64; HISTOGRAM_BUCKETS];
100 for (i, b) in self.buckets.iter().enumerate() {
101 buckets[i] = b.load(Ordering::Relaxed);
102 }
103 HistogramSnapshot {
104 buckets,
105 overflow: self.overflow.load(Ordering::Relaxed),
106 sum_us: self.sum_us.load(Ordering::Relaxed),
107 count: self.count.load(Ordering::Relaxed),
108 max_us: self.max_us.load(Ordering::Relaxed),
109 }
110 }
111
112 pub fn reset(&self) {
114 for b in &self.buckets {
115 b.store(0, Ordering::Relaxed);
116 }
117 self.overflow.store(0, Ordering::Relaxed);
118 self.sum_us.store(0, Ordering::Relaxed);
119 self.count.store(0, Ordering::Relaxed);
120 self.max_us.store(0, Ordering::Relaxed);
121 }
122}
123
124#[derive(Debug, Clone)]
126pub struct HistogramSnapshot {
127 pub buckets: [u64; HISTOGRAM_BUCKETS],
128 pub overflow: u64,
129 pub sum_us: u64,
130 pub count: u64,
131 pub max_us: u64,
132}
133
134impl HistogramSnapshot {
135 #[must_use]
137 pub fn total_count(&self) -> u64 {
138 self.count
139 }
140
141 #[must_use]
143 pub fn mean_us(&self) -> Option<f64> {
144 if self.count == 0 {
145 None
146 } else {
147 Some(self.sum_us as f64 / self.count as f64)
148 }
149 }
150
151 #[must_use]
154 pub fn percentile_us(&self, p: f64) -> Option<u64> {
155 if self.count == 0 {
156 return None;
157 }
158 let target = ((self.count as f64) * (p / 100.0)).ceil() as u64;
159 let mut cumulative = 0u64;
160 for (i, &c) in self.buckets.iter().enumerate() {
161 cumulative += c;
162 if cumulative >= target {
163 let upper = 1u64 << (i + 1).min(63);
166 return Some(upper);
167 }
168 }
169 if cumulative + self.overflow >= target {
170 return Some(self.max_us);
172 }
173 Some(self.max_us)
174 }
175
176 #[must_use]
178 pub fn p50_p90_p99_p999(&self) -> Option<(u64, u64, u64, u64)> {
179 Some((
180 self.percentile_us(50.0)?,
181 self.percentile_us(90.0)?,
182 self.percentile_us(99.0)?,
183 self.percentile_us(99.9)?,
184 ))
185 }
186}
187
188fn registry() -> &'static RwLock<HashMap<String, Arc<LatencyHistogram>>> {
196 static REGISTRY: OnceLock<RwLock<HashMap<String, Arc<LatencyHistogram>>>> = OnceLock::new();
197 REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
198}
199
200pub fn record_global(tag: &str, us: u64) {
204 {
206 let r = registry().read().expect("histogram registry poisoned");
207 if let Some(h) = r.get(tag) {
208 h.record_us(us);
209 return;
210 }
211 }
212 let mut w = registry().write().expect("histogram registry poisoned");
214 let h = w.entry(tag.to_string()).or_insert_with(|| Arc::new(LatencyHistogram::new())).clone();
215 drop(w);
216 h.record_us(us);
217}
218
219#[must_use]
221pub fn snapshot_all() -> HashMap<String, HistogramSnapshot> {
222 let r = registry().read().expect("histogram registry poisoned");
223 r.iter().map(|(k, h)| (k.clone(), h.snapshot())).collect()
224}
225
226pub fn reset_all() {
229 let r = registry().read().expect("histogram registry poisoned");
230 for h in r.values() {
231 h.reset();
232 }
233}
234
235pub fn emit_snapshot_jsonl<W: std::io::Write>(out: &mut W) -> std::io::Result<()> {
239 let snaps = snapshot_all();
240 for (tag, snap) in snaps {
241 if snap.total_count() == 0 {
242 continue;
243 }
244 let (p50, p90, p99, p999) = snap.p50_p90_p99_p999().unwrap_or((0, 0, 0, 0));
245 writeln!(
246 out,
247 "[MyelonInstr] {{\"scope\":\"wmbt_kv_latency_histogram\",\"tag\":\"{tag}\",\
248 \"count\":{},\"mean_us\":{:.1},\"p50_us\":{},\"p90_us\":{},\
249 \"p99_us\":{},\"p999_us\":{},\"max_us\":{},\"overflow\":{}}}",
250 snap.total_count(),
251 snap.mean_us().unwrap_or(0.0),
252 p50,
253 p90,
254 p99,
255 p999,
256 snap.max_us,
257 snap.overflow,
258 )?;
259 }
260 Ok(())
261}
262
263fn bucket_for(us: u64) -> usize {
267 if us <= 1 {
268 0
269 } else {
270 let bits = 64 - us.leading_zeros() as usize - 1;
272 bits.min(HISTOGRAM_BUCKETS)
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn bucket_for_known_values() {
282 assert_eq!(bucket_for(0), 0);
283 assert_eq!(bucket_for(1), 0);
284 assert_eq!(bucket_for(2), 1);
285 assert_eq!(bucket_for(3), 1);
286 assert_eq!(bucket_for(4), 2);
287 assert_eq!(bucket_for(7), 2);
288 assert_eq!(bucket_for(8), 3);
289 assert_eq!(bucket_for(1024), 10);
290 assert_eq!(bucket_for(1_000_000), 19); }
292
293 #[test]
294 fn empty_snapshot_has_no_percentile() {
295 let h = LatencyHistogram::new();
296 let snap = h.snapshot();
297 assert!(snap.percentile_us(50.0).is_none());
298 assert!(snap.mean_us().is_none());
299 assert_eq!(snap.total_count(), 0);
300 }
301
302 #[test]
303 fn percentile_walks_buckets() {
304 let h = LatencyHistogram::new();
305 for us in [100, 200, 300, 400, 500, 1000, 2000, 3000, 4000, 5000] {
306 h.record_us(us);
307 }
308 let snap = h.snapshot();
309 assert_eq!(snap.total_count(), 10);
310 let p50 = snap.percentile_us(50.0).unwrap();
313 assert!(p50 >= 128, "p50={p50}");
314 assert!(p50 <= 1024, "p50={p50}");
315 let p99 = snap.percentile_us(99.0).unwrap();
317 assert!(p99 >= 8192, "p99={p99}");
318 }
319
320 #[test]
321 fn reset_zeroes_all_counters() {
322 let h = LatencyHistogram::new();
323 h.record_us(1000);
324 h.record_us(2000);
325 assert_eq!(h.snapshot().total_count(), 2);
326 h.reset();
327 assert_eq!(h.snapshot().total_count(), 0);
328 assert!(h.snapshot().mean_us().is_none());
329 }
330
331 #[test]
332 fn overflow_bucket_catches_extreme_values() {
333 let h = LatencyHistogram::new();
334 h.record_us(2u64.pow(31));
336 let snap = h.snapshot();
337 assert_eq!(snap.overflow, 1);
338 assert_eq!(snap.total_count(), 1);
339 }
340
341 #[test]
342 fn mean_and_max_match() {
343 let h = LatencyHistogram::new();
344 h.record_us(100);
345 h.record_us(200);
346 h.record_us(300);
347 let snap = h.snapshot();
348 assert_eq!(snap.max_us, 300);
349 assert!((snap.mean_us().unwrap() - 200.0).abs() < 0.1);
350 }
351
352 #[test]
353 fn lock_free_concurrent_inserts() {
354 use std::thread;
355 let h = Arc::new(LatencyHistogram::new());
356 let mut handles = vec![];
357 for t in 0..8 {
358 let h = Arc::clone(&h);
359 handles.push(thread::spawn(move || {
360 for i in 0..1000 {
361 h.record_us((t * 1000 + i) as u64 + 1);
362 }
363 }));
364 }
365 for h in handles {
366 h.join().unwrap();
367 }
368 assert_eq!(h.snapshot().total_count(), 8000);
369 }
370
371 #[test]
372 fn global_registry_creates_histograms_on_first_use() {
373 let tag = "test_global_registry_creates_v1";
376 record_global(tag, 100);
377 record_global(tag, 200);
378 record_global(tag, 300);
379 let snaps = snapshot_all();
380 let snap = snaps.get(tag).expect("registered");
381 assert_eq!(snap.total_count(), 3);
382 assert!(snap.mean_us().unwrap() > 100.0);
383 }
384
385 #[test]
386 fn emit_snapshot_jsonl_writes_one_line_per_tag() {
387 let tag = "test_emit_jsonl_v1";
388 record_global(tag, 1000);
389 let mut buf = Vec::new();
390 emit_snapshot_jsonl(&mut buf).unwrap();
391 let text = String::from_utf8(buf).unwrap();
392 let our_line = text.lines().find(|l| l.contains(tag)).expect("our tag in jsonl");
394 assert!(our_line.contains("p50_us"));
395 assert!(our_line.contains("\"count\":"));
396 }
397}