Skip to main content

wombatkv_node/
latency_histogram.rs

1//! Fixed-bucket latency histogram with lock-free inserts + a global
2//! registry keyed by `(fn, path)` tag for emit_timing integration.
3//!
4//! Records microsecond latencies into log-scale buckets (powers of
5//! 2). 30 buckets covers 1µs to ~1 hour, enough for our actual
6//! workload (S3 GET 10-100ms, Metal decode 1-2s, daemon RPC <µs to
7//! ms). p50/p90/p99/p99.9 derived via bucket-boundary interpolation.
8//!
9//! # Why fixed buckets vs HDR or T-digest
10//!
11//! - **HDR** (hdrhistogram crate): more accurate but heavy dep and
12//!   each instance is ~100KB. We want one histogram per (fn, path)
13//!   tag, easily 20+ tags → 2MB+ per daemon. Overkill for alpha.
14//! - **T-digest**: better accuracy at the tails but requires a
15//!   merge operation that's not lock-free.
16//! - **Fixed log-scale buckets**: cheap (240 bytes per histogram),
17//!   lock-free, perfect-enough percentile accuracy for our alpha
18//!   "is the tail blowing up?" question.
19//!
20//! Each bucket boundary = `2^bucket_idx` microseconds. Bucket N
21//! covers `[2^N, 2^(N+1))` µs. Bucket 0 = 1-2µs; bucket 20 = 1.05s
22//! to 2.1s; bucket 30 = ~17min to ~34min.
23//!
24//! # Use
25//!
26//! ```ignore
27//! use wombatkv_node::latency_histogram::LatencyHistogram;
28//! let h = LatencyHistogram::new();
29//! h.record_us(123);
30//! h.record_us(456);
31//! let snap = h.snapshot();
32//! assert!(snap.total_count() == 2);
33//! ```
34
35use std::collections::HashMap;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, OnceLock, RwLock};
38
39/// Number of log-scale buckets. 30 covers 1µs to ~17min.
40pub const HISTOGRAM_BUCKETS: usize = 30;
41
42/// Lock-free fixed-bucket latency histogram.
43///
44/// Hot-path `record_us` is one `floor(log2(us))` + one atomic
45/// increment. Read-path `snapshot` reads all buckets non-atomically
46/// (relaxed); concurrent records during a snapshot may miss the
47/// snapshot or be counted partially, which is fine for percentile
48/// estimation.
49pub struct LatencyHistogram {
50    buckets: [AtomicU64; HISTOGRAM_BUCKETS],
51    overflow: AtomicU64, // > 2^HISTOGRAM_BUCKETS µs
52    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        // Cannot construct [T; N] with non-Copy T directly; use array::from_fn.
67        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    /// Record a latency measurement in microseconds.
77    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        // Max via CAS loop; uncontended in practice.
87        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    /// Snapshot the histogram for percentile reading.
97    #[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    /// Reset all counters to zero. Useful for periodic-emit windows.
113    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/// Point-in-time snapshot of a `LatencyHistogram`.
125#[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    /// Total number of samples recorded.
136    #[must_use]
137    pub fn total_count(&self) -> u64 {
138        self.count
139    }
140
141    /// Mean latency in microseconds. `None` if no samples.
142    #[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    /// Percentile in microseconds via bucket-boundary interpolation.
152    /// `p` is in `[0.0, 100.0]`. Returns `None` if no samples.
153    #[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                // Bucket i covers [2^i, 2^(i+1)) µs. Return the
164                // bucket upper bound as a conservative estimate.
165                let upper = 1u64 << (i + 1).min(63);
166                return Some(upper);
167            }
168        }
169        if cumulative + self.overflow >= target {
170            // Fell into the overflow bucket; return max observed.
171            return Some(self.max_us);
172        }
173        Some(self.max_us)
174    }
175
176    /// Convenience: the four percentiles we usually want.
177    #[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
188// =========================================================================
189// Global per-(fn, path) registry
190// =========================================================================
191
192/// Process-wide registry mapping `tag` strings (typically
193/// `"<func>:<path>"`) to histogram instances. Lazy-initialized on
194/// first record.
195fn 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
200/// Record a latency observation against the global registry under
201/// `tag`. Creates the histogram on first use. Read-lock-fast path
202/// for repeat tags; write-lock only on first-use insert.
203pub fn record_global(tag: &str, us: u64) {
204    // Hot path: read-lock, find tag, record.
205    {
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    // Cold path: write-lock, get-or-insert, record.
213    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/// Snapshot every histogram in the registry.
220#[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
226/// Reset every histogram in the registry. Useful for periodic-emit
227/// windows where each window's stats are reported then zeroed.
228pub fn reset_all() {
229    let r = registry().read().expect("histogram registry poisoned");
230    for h in r.values() {
231        h.reset();
232    }
233}
234
235/// Emit a single MyelonInstr line per (tag, snapshot), the same
236/// shape as `embed::emit_timing` events so existing log consumers
237/// can pick it up without schema changes.
238pub 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
263/// `floor(log2(us))` for bucket selection. `us == 0` → 0 (the
264/// 1-2µs bucket). Saturates at HISTOGRAM_BUCKETS for the overflow
265/// path.
266fn bucket_for(us: u64) -> usize {
267    if us <= 1 {
268        0
269    } else {
270        // 63 - leading_zeros gives floor(log2)
271        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); // ~1s
291    }
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        // p50 should land in a low bucket (100-500µs range → bucket
311        // 6-9, upper bound 128-1024µs).
312        let p50 = snap.percentile_us(50.0).unwrap();
313        assert!(p50 >= 128, "p50={p50}");
314        assert!(p50 <= 1024, "p50={p50}");
315        // p99 should be in the high bucket (5000µs → bucket 12, upper 8192).
316        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        // ~17 minutes, beyond HISTOGRAM_BUCKETS=30 boundary
335        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        // Use a tag that no other test in this crate writes to so we
374        // get a clean baseline.
375        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        // The line for OUR tag (others may exist from concurrent tests).
393        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}