1use 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 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 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 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 pub fn n_buckets(&self) -> usize { self.n_boundaries + 1 }
209
210 pub fn total_count(&self) -> u64 {
212 self.header().total_count.load(Ordering::Acquire)
213 }
214
215 pub fn bucket_for(&self, value: u64) -> usize {
217 let bounds = self.boundaries();
218 bounds.partition_point(|&b| b <= value)
222 }
223
224 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 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 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 pub fn boundaries_vec(&self) -> Vec<u64> {
258 self.boundaries().to_vec()
259 }
260
261 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 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 bounds.last().copied().unwrap_or(0)
290 }
291
292 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); 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 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 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 for v in 0..100u64 { h.record(v % 10); }
432 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 let value = match (t * per_thread + i) % 4 {
499 0 => 5, 1 => 50, 2 => 500, _ => 5000, };
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 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 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 for i in 0..1000u64 {
550 let latency_us = match i % 100 {
551 0..=80 => 5 + (i % 5), 81..=95 => 50 + (i % 50), _ => 500 + (i * 10), };
555 h.record(latency_us);
556 }
557 let p50 = h.percentile(0.5);
559 assert!(p50 < 10, "p50 {p50} should be under 10us");
560 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 assert_eq!(h.count(2).err(), Some(HistogramError::OutOfBounds));
572 std::fs::remove_file(&p).ok();
573 }
574}