Skip to main content

subetha_cxc/
shared_histogram.rs

1//! `SharedHistogram` - cross-process bucketed counter for
2//! distribution tracking.
3//!
4//! Fixed-bucket histogram: caller supplies N bucket boundaries at
5//! create time. `record(value)` finds the right bucket via binary
6//! search and atomically increments its counter. Useful for
7//! latency distributions, request-size distributions, queue-depth
8//! sampling - anything where N distributed processes need to
9//! aggregate "how many in each bucket" into one shared view.
10//!
11//! # Bucket semantics
12//!
13//! For boundaries `[b0, b1, b2, ..., bN-1]`:
14//! - Bucket 0: values `value < b0`
15//! - Bucket i (1..N-1): values `b{i-1} <= value < bi`
16//! - Bucket N: values `value >= b{N-1}` (the overflow bucket)
17//!
18//! So a histogram with K boundaries has K+1 buckets.
19//!
20//! # Layout
21//!
22//! Single MMF file:
23//!
24//! ```text
25//! +---------------------------+
26//! | HistogramHeader (64B)     |
27//! |   magic, n_boundaries     |
28//! |   total_count: AtomicU64  |
29//! +---------------------------+
30//! | boundaries [u64; N]       |  ascending; verified at open
31//! +---------------------------+
32//! | counters [AtomicU64; N+1] |  one per bucket
33//! +---------------------------+
34//! ```
35//!
36//! # Concurrency
37//!
38//! Each bucket's counter is its own AtomicU64. `record` uses
39//! `fetch_add(1, AcqRel)` to atomically increment; multiple
40//! recorders contend only on the SAME bucket's cache line
41//! (different buckets are fully concurrent).
42//!
43//! # Percentile estimation
44//!
45//! `percentile(p)` walks buckets accumulating counts until p of the
46//! total is covered, then linearly interpolates within the target
47//! bucket. For coarse boundaries the estimate has bucket-width
48//! granularity; for log-spaced boundaries that's typically <1
49//! decade error which suffices for latency dashboards.
50
51use std::fs::{File, OpenOptions};
52use std::mem::size_of;
53use std::path::Path;
54use std::sync::atomic::{AtomicU64, Ordering};
55
56use memmap2::{MmapMut, MmapOptions};
57
58pub const HISTOGRAM_MAGIC: u64 = 0x4150_4849_5354_3031;
59
60#[repr(C, align(64))]
61pub struct HistogramHeader {
62    pub magic: u64,
63    pub n_boundaries: u32,
64    _pad1: u32,
65    pub total_count: AtomicU64,
66    _pad2: [u8; 40],
67}
68
69const _: () = {
70    assert!(size_of::<HistogramHeader>() == 64);
71};
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum HistogramError {
75    EmptyBoundaries,
76    NonMonotonicBoundaries,
77    LayoutMismatch,
78    OutOfBounds,
79    IoError(std::io::ErrorKind),
80}
81
82impl From<std::io::Error> for HistogramError {
83    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
84}
85
86pub const fn histogram_file_size(n_boundaries: usize) -> usize {
87    size_of::<HistogramHeader>()
88        + n_boundaries * size_of::<u64>()
89        + (n_boundaries + 1) * size_of::<AtomicU64>()
90}
91
92pub struct SharedHistogram {
93    _file: File,
94    mmap: MmapMut,
95    n_boundaries: usize,
96    boundaries_offset: usize,
97    counters_offset: usize,
98    header_sidecar: subetha_core::HandshakeHeader,
99    ring_sidecar: Box<subetha_core::ObservationRing>,
100}
101
102unsafe impl Send for SharedHistogram {}
103unsafe impl Sync for SharedHistogram {}
104
105impl subetha_sidecar::AdaptiveInstance for SharedHistogram {
106    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
107    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
108    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
109        Box::new(subetha_sidecar::NoMigrationPolicy)
110    }
111}
112
113impl SharedHistogram {
114    pub fn create(
115        path: impl AsRef<Path>, boundaries: &[u64],
116    ) -> Result<Self, HistogramError> {
117        if boundaries.is_empty() {
118            return Err(HistogramError::EmptyBoundaries);
119        }
120        for w in boundaries.windows(2) {
121            if w[0] >= w[1] {
122                return Err(HistogramError::NonMonotonicBoundaries);
123            }
124        }
125        let n_boundaries = boundaries.len();
126        let total = histogram_file_size(n_boundaries);
127        let file = OpenOptions::new()
128            .read(true).write(true).create(true).truncate(true)
129            .open(path.as_ref())?;
130        file.set_len(total as u64)?;
131        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
132        let hdr = mmap.as_mut_ptr() as *mut HistogramHeader;
133        unsafe {
134            std::ptr::write_bytes(hdr as *mut u8, 0, size_of::<HistogramHeader>());
135            (*hdr).magic = HISTOGRAM_MAGIC;
136            (*hdr).n_boundaries = n_boundaries as u32;
137        }
138        let boundaries_offset = size_of::<HistogramHeader>();
139        let counters_offset = boundaries_offset + std::mem::size_of_val(boundaries);
140        // Write boundaries.
141        unsafe {
142            let dst = mmap.as_mut_ptr().add(boundaries_offset) as *mut u64;
143            std::ptr::copy_nonoverlapping(boundaries.as_ptr(), dst, n_boundaries);
144        }
145        // Counters are already zero from set_len + map_mut.
146        Ok(Self {
147            _file: file, mmap, n_boundaries,
148            boundaries_offset, counters_offset,
149            header_sidecar: subetha_core::HandshakeHeader::new(),
150            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
151        })
152    }
153
154    pub fn open(
155        path: impl AsRef<Path>, expected_boundaries: &[u64],
156    ) -> Result<Self, HistogramError> {
157        let n_boundaries = expected_boundaries.len();
158        if n_boundaries == 0 { return Err(HistogramError::EmptyBoundaries); }
159        let total = histogram_file_size(n_boundaries);
160        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
161        if file.metadata()?.len() < total as u64 {
162            return Err(HistogramError::LayoutMismatch);
163        }
164        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
165        let hdr = unsafe { &*(mmap.as_ptr() as *const HistogramHeader) };
166        if hdr.magic != HISTOGRAM_MAGIC || hdr.n_boundaries != n_boundaries as u32 {
167            return Err(HistogramError::LayoutMismatch);
168        }
169        let boundaries_offset = size_of::<HistogramHeader>();
170        let counters_offset = boundaries_offset + std::mem::size_of_val(expected_boundaries);
171        // Verify stored boundaries match expected.
172        let stored = unsafe {
173            std::slice::from_raw_parts(
174                mmap.as_ptr().add(boundaries_offset) as *const u64,
175                n_boundaries,
176            )
177        };
178        if stored != expected_boundaries {
179            return Err(HistogramError::LayoutMismatch);
180        }
181        Ok(Self {
182            _file: file, mmap, n_boundaries,
183            boundaries_offset, counters_offset,
184            header_sidecar: subetha_core::HandshakeHeader::new(),
185            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
186        })
187    }
188
189    fn header(&self) -> &HistogramHeader {
190        unsafe { &*(self.mmap.as_ptr() as *const HistogramHeader) }
191    }
192
193    fn boundaries(&self) -> &[u64] {
194        unsafe {
195            std::slice::from_raw_parts(
196                self.mmap.as_ptr().add(self.boundaries_offset) as *const u64,
197                self.n_boundaries,
198            )
199        }
200    }
201
202    fn counter(&self, bucket_idx: usize) -> &AtomicU64 {
203        let base = unsafe { self.mmap.as_ptr().add(self.counters_offset) };
204        unsafe { &*(base.add(bucket_idx * size_of::<AtomicU64>()) as *const AtomicU64) }
205    }
206
207    /// Number of buckets (n_boundaries + 1).
208    pub fn n_buckets(&self) -> usize { self.n_boundaries + 1 }
209
210    /// Total count across all buckets.
211    pub fn total_count(&self) -> u64 {
212        self.header().total_count.load(Ordering::Acquire)
213    }
214
215    /// Find the bucket index for `value`. Binary search on boundaries.
216    pub fn bucket_for(&self, value: u64) -> usize {
217        let bounds = self.boundaries();
218        // partition_point returns the first index where the predicate
219        // is false. With `|&b| b <= value`, it returns the first
220        // boundary GREATER than value, i.e., the bucket index.
221        bounds.partition_point(|&b| b <= value)
222    }
223
224    /// Record one observation of `value`. Atomically increments the
225    /// matching bucket counter and the total. Returns the bucket
226    /// index it landed in.
227    pub fn record(&self, value: u64) -> usize {
228        let idx = self.bucket_for(value);
229        self.counter(idx).fetch_add(1, Ordering::AcqRel);
230        self.header().total_count.fetch_add(1, Ordering::AcqRel);
231        self.ring_sidecar
232            .push_op(crate::sidecar_ops::histogram::OP_RECORD, 0);
233        idx
234    }
235
236    /// Read a specific bucket's count.
237    pub fn count(&self, bucket_idx: usize) -> Result<u64, HistogramError> {
238        if bucket_idx >= self.n_buckets() {
239            self.ring_sidecar
240                .push_op(crate::sidecar_ops::histogram::OP_COUNT, 1);
241            return Err(HistogramError::OutOfBounds);
242        }
243        let v = self.counter(bucket_idx).load(Ordering::Acquire);
244        self.ring_sidecar
245            .push_op(crate::sidecar_ops::histogram::OP_COUNT, 0);
246        Ok(v)
247    }
248
249    /// Snapshot all bucket counts as a Vec.
250    pub fn counts(&self) -> Vec<u64> {
251        (0..self.n_buckets())
252            .map(|i| self.counter(i).load(Ordering::Acquire))
253            .collect()
254    }
255
256    /// Get the boundaries vector (copy).
257    pub fn boundaries_vec(&self) -> Vec<u64> {
258        self.boundaries().to_vec()
259    }
260
261    /// Estimate the p-th percentile (p in 0.0..=1.0). Walks buckets
262    /// accumulating counts until p of total is covered, then linearly
263    /// interpolates within the target bucket. Returns 0 if total is 0.
264    pub fn percentile(&self, p: f64) -> u64 {
265        let p = p.clamp(0.0, 1.0);
266        let total = self.total_count();
267        self.ring_sidecar.push_op(
268            crate::sidecar_ops::histogram::OP_PERCENTILE,
269            if total == 0 { 2 } else { 0 },
270        );
271        if total == 0 { return 0; }
272        let target = (total as f64 * p).round() as u64;
273        let mut acc = 0u64;
274        let counts = self.counts();
275        let bounds = self.boundaries();
276        for (i, &c) in counts.iter().enumerate() {
277            let new_acc = acc.saturating_add(c);
278            if new_acc >= target {
279                // Target falls in bucket i. Interpolate within.
280                let lo = if i == 0 { 0 } else { bounds[i - 1] };
281                let hi = if i < bounds.len() { bounds[i] } else { lo.saturating_mul(2) };
282                if c == 0 { return lo; }
283                let frac = (target - acc) as f64 / c as f64;
284                return lo + ((hi - lo) as f64 * frac) as u64;
285            }
286            acc = new_acc;
287        }
288        // Shouldn't reach; return last boundary as fallback.
289        bounds.last().copied().unwrap_or(0)
290    }
291
292    /// Reset all counters to 0. Not concurrency-coordinated; expect
293    /// transient race with concurrent recorders.
294    pub fn reset(&self) {
295        for i in 0..self.n_buckets() {
296            self.counter(i).store(0, Ordering::Release);
297        }
298        self.header().total_count.store(0, Ordering::Release);
299    }
300
301    pub fn flush(&self) -> Result<(), HistogramError> {
302        self.mmap.flush()?;
303        Ok(())
304    }
305
306    pub fn flush_async(&self) -> Result<(), HistogramError> {
307        self.mmap.flush_async()?;
308        Ok(())
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::sync::Arc;
316    use std::thread;
317
318    fn tmp(name: &str) -> std::path::PathBuf {
319        let mut p = std::env::temp_dir();
320        let pid = std::process::id();
321        p.push(format!("subetha-histogram-{name}-{pid}.bin"));
322        p
323    }
324
325    #[test]
326    fn create_initial_state_is_empty() {
327        let p = tmp("init");
328        let h = SharedHistogram::create(&p, &[10, 100, 1000]).unwrap();
329        assert_eq!(h.n_buckets(), 4);  // 3 boundaries -> 4 buckets
330        assert_eq!(h.total_count(), 0);
331        for i in 0..h.n_buckets() {
332            assert_eq!(h.count(i).unwrap(), 0);
333        }
334        std::fs::remove_file(&p).ok();
335    }
336
337    #[test]
338    fn empty_boundaries_rejected() {
339        let p = tmp("empty");
340        assert_eq!(
341            SharedHistogram::create(&p, &[]).err(),
342            Some(HistogramError::EmptyBoundaries)
343        );
344        std::fs::remove_file(&p).ok();
345    }
346
347    #[test]
348    fn non_monotonic_boundaries_rejected() {
349        let p = tmp("non-mono");
350        assert_eq!(
351            SharedHistogram::create(&p, &[10, 5, 20]).err(),
352            Some(HistogramError::NonMonotonicBoundaries)
353        );
354        assert_eq!(
355            SharedHistogram::create(&p, &[10, 10]).err(),
356            Some(HistogramError::NonMonotonicBoundaries)
357        );
358        std::fs::remove_file(&p).ok();
359    }
360
361    #[test]
362    fn bucket_assignment_correct() {
363        let p = tmp("bucket-assign");
364        // Boundaries: [10, 100, 1000]
365        // Bucket 0: value < 10
366        // Bucket 1: 10 <= value < 100
367        // Bucket 2: 100 <= value < 1000
368        // Bucket 3: value >= 1000
369        let h = SharedHistogram::create(&p, &[10, 100, 1000]).unwrap();
370        assert_eq!(h.bucket_for(0), 0);
371        assert_eq!(h.bucket_for(9), 0);
372        assert_eq!(h.bucket_for(10), 1);
373        assert_eq!(h.bucket_for(99), 1);
374        assert_eq!(h.bucket_for(100), 2);
375        assert_eq!(h.bucket_for(999), 2);
376        assert_eq!(h.bucket_for(1000), 3);
377        assert_eq!(h.bucket_for(u64::MAX), 3);
378        std::fs::remove_file(&p).ok();
379    }
380
381    #[test]
382    fn record_increments_correct_bucket() {
383        let p = tmp("record");
384        let h = SharedHistogram::create(&p, &[10, 100, 1000]).unwrap();
385        let inputs = [5, 5, 50, 500, 5000, 50_000];
386        for &v in &inputs {
387            h.record(v);
388        }
389        // Bucket 0 (< 10): 2 (the two 5s)
390        // Bucket 1 (10..100): 1 (the 50)
391        // Bucket 2 (100..1000): 1 (the 500)
392        // Bucket 3 (>= 1000): 2 (5000 and 50_000)
393        assert_eq!(h.count(0).unwrap(), 2);
394        assert_eq!(h.count(1).unwrap(), 1);
395        assert_eq!(h.count(2).unwrap(), 1);
396        assert_eq!(h.count(3).unwrap(), 2);
397        assert_eq!(h.total_count(), 6);
398        std::fs::remove_file(&p).ok();
399    }
400
401    #[test]
402    fn record_returns_bucket_index() {
403        let p = tmp("record-idx");
404        let h = SharedHistogram::create(&p, &[10, 100]).unwrap();
405        assert_eq!(h.record(5), 0);
406        assert_eq!(h.record(50), 1);
407        assert_eq!(h.record(500), 2);
408        std::fs::remove_file(&p).ok();
409    }
410
411    #[test]
412    fn counts_snapshot_returns_all_buckets() {
413        let p = tmp("counts");
414        let h = SharedHistogram::create(&p, &[10, 100]).unwrap();
415        h.record(5);
416        h.record(50);
417        h.record(50);
418        h.record(500);
419        h.record(500);
420        h.record(500);
421        let counts = h.counts();
422        assert_eq!(counts, vec![1, 2, 3]);
423        std::fs::remove_file(&p).ok();
424    }
425
426    #[test]
427    fn percentile_basic() {
428        let p = tmp("p-basic");
429        let h = SharedHistogram::create(&p, &[10, 100, 1000]).unwrap();
430        // 100 values uniformly in 0..10. All land in bucket 0.
431        for v in 0..100u64 { h.record(v % 10); }
432        // p50 should be ~5 (within bucket 0 interpolation).
433        let p50 = h.percentile(0.5);
434        assert!(p50 <= 10, "p50 {p50} should be in bucket 0 (<10)");
435        std::fs::remove_file(&p).ok();
436    }
437
438    #[test]
439    fn percentile_zero_total_returns_zero() {
440        let p = tmp("p-zero");
441        let h = SharedHistogram::create(&p, &[10]).unwrap();
442        assert_eq!(h.percentile(0.5), 0);
443        assert_eq!(h.percentile(0.99), 0);
444        std::fs::remove_file(&p).ok();
445    }
446
447    #[test]
448    fn reset_clears_all_buckets() {
449        let p = tmp("reset");
450        let h = SharedHistogram::create(&p, &[10, 100]).unwrap();
451        for _ in 0..5 { h.record(50); }
452        assert_eq!(h.total_count(), 5);
453        h.reset();
454        assert_eq!(h.total_count(), 0);
455        for i in 0..h.n_buckets() {
456            assert_eq!(h.count(i).unwrap(), 0);
457        }
458        std::fs::remove_file(&p).ok();
459    }
460
461    #[test]
462    fn cross_handle_visibility() {
463        let p = tmp("cross-handle");
464        let writer = SharedHistogram::create(&p, &[10, 100, 1000]).unwrap();
465        let reader = SharedHistogram::open(&p, &[10, 100, 1000]).unwrap();
466        writer.record(50);
467        writer.record(500);
468        assert_eq!(reader.count(1).unwrap(), 1);
469        assert_eq!(reader.count(2).unwrap(), 1);
470        assert_eq!(reader.total_count(), 2);
471        std::fs::remove_file(&p).ok();
472    }
473
474    #[test]
475    fn open_with_wrong_boundaries_rejected() {
476        let p = tmp("wrong-bounds");
477        let _w = SharedHistogram::create(&p, &[10, 100]).unwrap();
478        assert!(matches!(
479            SharedHistogram::open(&p, &[10, 200]),
480            Err(HistogramError::LayoutMismatch)
481        ));
482        std::fs::remove_file(&p).ok();
483    }
484
485    #[test]
486    fn concurrent_recorders_no_lost_updates() {
487        let p = tmp("concurrent");
488        let h: Arc<SharedHistogram>
489            = Arc::new(SharedHistogram::create(&p, &[10, 100, 1000]).unwrap());
490        let n_threads = 4;
491        let per_thread = 250;
492        let mut handles = vec![];
493        for t in 0..n_threads {
494            let h = h.clone();
495            handles.push(thread::spawn(move || {
496                for i in 0..per_thread {
497                    // Distribute across buckets via modulo.
498                    let value = match (t * per_thread + i) % 4 {
499                        0 => 5,    // bucket 0
500                        1 => 50,   // bucket 1
501                        2 => 500,  // bucket 2
502                        _ => 5000, // bucket 3
503                    };
504                    h.record(value);
505                }
506            }));
507        }
508        for h in handles { h.join().unwrap(); }
509        let total = n_threads * per_thread;
510        assert_eq!(h.total_count() as usize, total);
511        let counts = h.counts();
512        // Each bucket should have ~total/4 records.
513        let expected_per = (total / 4) as u64;
514        for (i, &c) in counts.iter().enumerate() {
515            assert_eq!(c, expected_per,
516                "bucket {i} count {c} should be {expected_per}");
517        }
518        std::fs::remove_file(&p).ok();
519    }
520
521    #[test]
522    fn disk_persistence_survives_reopen() {
523        let p = tmp("disk");
524        let bounds = vec![10u64, 100, 1000];
525        {
526            let h = SharedHistogram::create(&p, &bounds).unwrap();
527            h.record(5);
528            h.record(50);
529            h.record(50);
530            h.record(5000);
531            h.flush().unwrap();
532        }
533        let h2 = SharedHistogram::open(&p, &bounds).unwrap();
534        assert_eq!(h2.count(0).unwrap(), 1);
535        assert_eq!(h2.count(1).unwrap(), 2);
536        assert_eq!(h2.count(2).unwrap(), 0);
537        assert_eq!(h2.count(3).unwrap(), 1);
538        assert_eq!(h2.total_count(), 4);
539        std::fs::remove_file(&p).ok();
540    }
541
542    #[test]
543    fn latency_distribution_pattern() {
544        // Realistic latency histogram: log-spaced boundaries in us.
545        let p = tmp("latency");
546        let bounds = vec![10u64, 100, 1_000, 10_000, 100_000, 1_000_000];
547        let h = SharedHistogram::create(&p, &bounds).unwrap();
548        // Simulate 1000 measurements; mostly fast, some tail.
549        for i in 0..1000u64 {
550            let latency_us = match i % 100 {
551                0..=80 => 5 + (i % 5),    // 81% under 10us
552                81..=95 => 50 + (i % 50), // 15% in 10..100us
553                _ => 500 + (i * 10),      // tail
554            };
555            h.record(latency_us);
556        }
557        // p50 should be in bucket 0 (< 10us).
558        let p50 = h.percentile(0.5);
559        assert!(p50 < 10, "p50 {p50} should be under 10us");
560        // p99 should be much higher (in the tail).
561        let p99 = h.percentile(0.99);
562        assert!(p99 > 100, "p99 {p99} should be over 100us");
563        std::fs::remove_file(&p).ok();
564    }
565
566    #[test]
567    fn count_out_of_bounds_rejected() {
568        let p = tmp("oob");
569        let h = SharedHistogram::create(&p, &[10]).unwrap();
570        // 2 buckets total (indices 0 and 1).
571        assert_eq!(h.count(2).err(), Some(HistogramError::OutOfBounds));
572        std::fs::remove_file(&p).ok();
573    }
574}