Skip to main content

subetha_cxc/
shared_count_min_sketch.rs

1//! `SharedCountMinSketch` - cross-process probabilistic frequency
2//! estimator.
3//!
4//! `d` hash functions, `w` cells per row. `insert(item)` does `d`
5//! atomic `fetch_add(1)` ops; `estimate_count(item)` reads `d`
6//! cells and returns the minimum (the tightest unbiased upper
7//! bound on true count).
8//!
9//! # Safety properties
10//!
11//! - Pure `fetch_add` writes - no underflow possible.
12//! - No spin loops, no CAS retries, no RAII guards.
13//! - Bounded memory at create time (`d * w` cells of u64).
14//! - Hash collisions OVERCOUNT, never undercount. Estimate is a
15//!   guaranteed upper bound on true count.
16//!
17//! # Error bound
18//!
19//! With probability `1 - delta`, estimate <= true + `epsilon * N`
20//! where N is total inserts. Sizing: `w >= e/epsilon`,
21//! `d >= ln(1/delta)`. Standard config (epsilon=0.001, delta=0.001):
22//! w=2718, d=7, mem ~152 KB.
23//!
24//! # Hash family
25//!
26//! `d` independent hash positions derived from one FNV-1a + fmix64
27//! hash, then `pos[i] = (h + i * h2) % w` using two-hash
28//! double-hashing (Kirsch-Mitzenmacher). Same technique as
29//! [`SharedBloomFilter`](crate::SharedBloomFilter).
30
31use std::fs::{File, OpenOptions};
32use std::mem::size_of;
33use std::path::Path;
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use memmap2::{MmapMut, MmapOptions};
37
38pub const CMS_MAGIC: u64 = 0x4150_434D_5330_3031;
39
40#[repr(C, align(64))]
41pub struct CMSHeader {
42    pub magic: u64,
43    pub d: u32,
44    pub w: u32,
45    pub total_inserts: AtomicU64,
46    _pad: [u8; 40],
47}
48
49const _: () = {
50    assert!(size_of::<CMSHeader>() == 64);
51};
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum CMSError {
55    InvalidConfig,
56    LayoutMismatch,
57    IoError(std::io::ErrorKind),
58}
59
60impl From<std::io::Error> for CMSError {
61    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
62}
63
64pub fn cms_file_size(d: u32, w: u32) -> usize {
65    size_of::<CMSHeader>() + (d as usize) * (w as usize) * size_of::<AtomicU64>()
66}
67
68const FNV_OFFSET_BASIS_1: u64 = 0xcbf2_9ce4_8422_2325;
69const FNV_OFFSET_BASIS_2: u64 = 0x8422_2325_cbf2_9ce4;
70const FNV_PRIME: u64 = 0x100_0000_01b3;
71
72#[inline]
73fn fnv1a(bytes: &[u8], basis: u64) -> u64 {
74    let mut h = basis;
75    for &b in bytes {
76        h ^= b as u64;
77        h = h.wrapping_mul(FNV_PRIME);
78    }
79    h
80}
81
82#[inline]
83fn fmix64(mut h: u64) -> u64 {
84    h ^= h >> 33;
85    h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
86    h ^= h >> 33;
87    h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
88    h ^= h >> 33;
89    h
90}
91
92pub struct SharedCountMinSketch {
93    _file: File,
94    mmap: MmapMut,
95    d: u32,
96    w: u32,
97    header_sidecar: subetha_core::HandshakeHeader,
98    ring_sidecar: Box<subetha_core::ObservationRing>,
99}
100
101unsafe impl Send for SharedCountMinSketch {}
102unsafe impl Sync for SharedCountMinSketch {}
103
104impl subetha_sidecar::AdaptiveInstance for SharedCountMinSketch {
105    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
106    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
107    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
108        Box::new(subetha_sidecar::NoMigrationPolicy)
109    }
110}
111
112impl SharedCountMinSketch {
113    /// Suggest (d, w) for target (epsilon, delta) error bounds.
114    /// Estimate <= true + epsilon * N with probability 1 - delta.
115    pub fn suggest_config(epsilon: f64, delta: f64) -> (u32, u32) {
116        assert!(epsilon > 0.0 && epsilon < 1.0);
117        assert!(delta > 0.0 && delta < 1.0);
118        let w = (std::f64::consts::E / epsilon).ceil() as u32;
119        let d = (1.0 / delta).ln().ceil() as u32;
120        (d.max(1), w.max(1))
121    }
122
123    pub fn create(
124        path: impl AsRef<Path>, d: u32, w: u32,
125    ) -> Result<Self, CMSError> {
126        if d == 0 || w == 0 {
127            return Err(CMSError::InvalidConfig);
128        }
129        let total = cms_file_size(d, w);
130        let file = OpenOptions::new()
131            .read(true).write(true).create(true).truncate(true)
132            .open(path.as_ref())?;
133        file.set_len(total as u64)?;
134        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
135        let hdr = mmap.as_mut_ptr() as *mut CMSHeader;
136        unsafe {
137            std::ptr::write_bytes(hdr as *mut u8, 0, size_of::<CMSHeader>());
138            (*hdr).magic = CMS_MAGIC;
139            (*hdr).d = d;
140            (*hdr).w = w;
141        }
142        Ok(Self {
143            _file: file, mmap, d, w,
144            header_sidecar: subetha_core::HandshakeHeader::new(),
145            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
146        })
147    }
148
149    pub fn open(
150        path: impl AsRef<Path>, expected_d: u32, expected_w: u32,
151    ) -> Result<Self, CMSError> {
152        let total = cms_file_size(expected_d, expected_w);
153        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
154        if file.metadata()?.len() < total as u64 {
155            return Err(CMSError::LayoutMismatch);
156        }
157        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
158        let hdr = unsafe { &*(mmap.as_ptr() as *const CMSHeader) };
159        if hdr.magic != CMS_MAGIC || hdr.d != expected_d || hdr.w != expected_w {
160            return Err(CMSError::LayoutMismatch);
161        }
162        Ok(Self {
163            _file: file, mmap, d: expected_d, w: expected_w,
164            header_sidecar: subetha_core::HandshakeHeader::new(),
165            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
166        })
167    }
168
169    #[inline]
170    pub fn d(&self) -> u32 { self.d }
171    #[inline]
172    pub fn w(&self) -> u32 { self.w }
173    #[inline]
174    pub fn total_inserts(&self) -> u64 {
175        unsafe { (*(self.mmap.as_ptr() as *const CMSHeader)).total_inserts.load(Ordering::Acquire) }
176    }
177
178    fn cell(&self, row: u32, col: u32) -> &AtomicU64 {
179        let idx = (row as usize) * (self.w as usize) + (col as usize);
180        let base = unsafe { self.mmap.as_ptr().add(size_of::<CMSHeader>()) };
181        unsafe { &*(base.add(idx * size_of::<AtomicU64>()) as *const AtomicU64) }
182    }
183
184    /// Invoke `f(row, col)` for each of the `d` hash positions of `item`,
185    /// without allocating. Two FNV-1a + fmix64 hashes feed
186    /// Kirsch-Mitzenmacher double-hashing `h1 + row * h2`; the column is
187    /// reduced mod `w`, using a bit-mask when `w` is a power of two (the
188    /// runtime divisor otherwise compiles to a hardware DIV per row).
189    #[inline]
190    fn for_each_position(&self, item: &[u8], mut f: impl FnMut(u32, u32)) {
191        let h1 = fmix64(fnv1a(item, FNV_OFFSET_BASIS_1));
192        let h2 = fmix64(fnv1a(item, FNV_OFFSET_BASIS_2));
193        let w = self.w as u64;
194        let pow2 = self.w.is_power_of_two();
195        let mask = w.wrapping_sub(1);
196        for row in 0..self.d {
197            let raw = h1.wrapping_add((row as u64).wrapping_mul(h2));
198            let col = if pow2 {
199                (raw & mask) as u32
200            } else {
201                // Lemire fastrange: multiply-shift maps into [0, w) with no
202                // hardware DIV. h1/h2 are fmix64-avalanched, so the high
203                // bits the shift keys on are well-distributed.
204                ((raw as u128 * w as u128) >> 64) as u32
205            };
206            f(row, col);
207        }
208    }
209
210    /// Insert one observation of `item`. `d` atomic increments.
211    pub fn insert(&self, item: &[u8]) {
212        self.for_each_position(item, |row, col| {
213            self.cell(row, col).fetch_add(1, Ordering::AcqRel);
214        });
215        unsafe {
216            (*(self.mmap.as_ptr() as *const CMSHeader))
217                .total_inserts.fetch_add(1, Ordering::AcqRel);
218        }
219        self.ring_sidecar
220            .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
221    }
222
223    /// Insert `count` observations at once (bulk increment).
224    pub fn insert_n(&self, item: &[u8], count: u64) {
225        if count == 0 { return; }
226        self.for_each_position(item, |row, col| {
227            self.cell(row, col).fetch_add(count, Ordering::AcqRel);
228        });
229        unsafe {
230            (*(self.mmap.as_ptr() as *const CMSHeader))
231                .total_inserts.fetch_add(count, Ordering::AcqRel);
232        }
233        self.ring_sidecar
234            .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
235    }
236
237    /// Estimate the frequency of `item`. Guaranteed upper bound on
238    /// true count.
239    pub fn estimate_count(&self, item: &[u8]) -> u64 {
240        let mut v = u64::MAX;
241        self.for_each_position(item, |row, col| {
242            let c = self.cell(row, col).load(Ordering::Acquire);
243            if c < v { v = c; }
244        });
245        let v = if self.d == 0 { 0 } else { v };
246        self.ring_sidecar.push_op(
247            crate::sidecar_ops::sketch::OP_QUERY,
248            if v == 0 { 2 } else { 0 },
249        );
250        v
251    }
252
253    /// Reset all cells to zero.
254    pub fn reset(&self) {
255        for row in 0..self.d {
256            for col in 0..self.w {
257                self.cell(row, col).store(0, Ordering::Release);
258            }
259        }
260        unsafe {
261            (*(self.mmap.as_ptr() as *const CMSHeader))
262                .total_inserts.store(0, Ordering::Release);
263        }
264        self.ring_sidecar
265            .push_op(crate::sidecar_ops::sketch::OP_CLEAR, 0);
266    }
267
268    pub fn flush(&self) -> Result<(), CMSError> {
269        self.mmap.flush()?;
270        Ok(())
271    }
272    pub fn flush_async(&self) -> Result<(), CMSError> {
273        self.mmap.flush_async()?;
274        Ok(())
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use std::sync::Arc;
282    use std::thread;
283
284    fn tmp(name: &str) -> std::path::PathBuf {
285        let mut p = std::env::temp_dir();
286        let pid = std::process::id();
287        p.push(format!("subetha-cms-{name}-{pid}.bin"));
288        p
289    }
290
291    #[test]
292    fn create_initial_state_zero() {
293        let p = tmp("init");
294        let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
295        assert_eq!(cms.d(), 4);
296        assert_eq!(cms.w(), 256);
297        assert_eq!(cms.total_inserts(), 0);
298        assert_eq!(cms.estimate_count(b"anything"), 0);
299        std::fs::remove_file(&p).ok();
300    }
301
302    #[test]
303    fn invalid_config_rejected() {
304        let p = tmp("invalid");
305        assert_eq!(
306            SharedCountMinSketch::create(&p, 0, 256).err(),
307            Some(CMSError::InvalidConfig)
308        );
309        assert_eq!(
310            SharedCountMinSketch::create(&p, 4, 0).err(),
311            Some(CMSError::InvalidConfig)
312        );
313        std::fs::remove_file(&p).ok();
314    }
315
316    #[test]
317    fn insert_then_estimate_returns_at_least_true_count() {
318        let p = tmp("insert");
319        let cms = SharedCountMinSketch::create(&p, 4, 1024).unwrap();
320        for _ in 0..5 { cms.insert(b"foo"); }
321        for _ in 0..3 { cms.insert(b"bar"); }
322        // Guaranteed: estimate >= true.
323        assert!(cms.estimate_count(b"foo") >= 5);
324        assert!(cms.estimate_count(b"bar") >= 3);
325        assert_eq!(cms.total_inserts(), 8);
326        std::fs::remove_file(&p).ok();
327    }
328
329    #[test]
330    fn insert_n_is_equivalent_to_n_inserts() {
331        let p = tmp("insert-n");
332        let a = SharedCountMinSketch::create(tmp("insert-n-a"), 4, 1024).unwrap();
333        let b = SharedCountMinSketch::create(tmp("insert-n-b"), 4, 1024).unwrap();
334        for _ in 0..100 { a.insert(b"item"); }
335        b.insert_n(b"item", 100);
336        assert_eq!(a.estimate_count(b"item"), b.estimate_count(b"item"));
337        assert_eq!(a.total_inserts(), b.total_inserts());
338        let _p = p;
339        std::fs::remove_file(tmp("insert-n-a")).ok();
340        std::fs::remove_file(tmp("insert-n-b")).ok();
341    }
342
343    #[test]
344    fn estimate_for_absent_item_is_low() {
345        // Probability of false-positive (estimate > 0) on absent
346        // item is bounded by (n / w)^d. For n=100 inserts, w=1024,
347        // d=4: (100/1024)^4 = 9e-5 - very low.
348        let p = tmp("absent");
349        let cms = SharedCountMinSketch::create(&p, 4, 1024).unwrap();
350        for i in 0..100u32 {
351            cms.insert(format!("inserted-{i}").as_bytes());
352        }
353        // Try 100 absent items; expect very few false positives.
354        let mut fp = 0u32;
355        for i in 0..100u32 {
356            if cms.estimate_count(format!("absent-{i}").as_bytes()) > 0 {
357                fp += 1;
358            }
359        }
360        // Allow up to 10% (well above theoretical bound).
361        assert!(fp < 10, "expected < 10 false positives, got {fp}");
362        std::fs::remove_file(&p).ok();
363    }
364
365    #[test]
366    fn heavy_hitter_detection() {
367        // Insert 1 heavy item (10000 times) + 1000 single-insert
368        // items. Heavy item should clearly stand out.
369        let p = tmp("heavy");
370        let cms = SharedCountMinSketch::create(&p, 5, 2048).unwrap();
371        for _ in 0..10_000 { cms.insert(b"HEAVY"); }
372        for i in 0..1000u32 {
373            cms.insert(format!("light-{i}").as_bytes());
374        }
375        let heavy = cms.estimate_count(b"HEAVY");
376        // Heavy is at least 10000; error <= epsilon * N where
377        // N = 11000 and epsilon ~ e/2048 ~ 0.00133. So error <= ~15.
378        assert!(heavy >= 10_000);
379        assert!(heavy <= 10_100, "heavy estimate {heavy} should be very close to 10000");
380        // Light items should estimate near 1.
381        for i in 0..10u32 {
382            let light = cms.estimate_count(format!("light-{i}").as_bytes());
383            assert!(light < 20, "light item {i} estimate {light} should be small");
384        }
385        std::fs::remove_file(&p).ok();
386    }
387
388    #[test]
389    fn reset_zeroes_everything() {
390        let p = tmp("reset");
391        let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
392        for _ in 0..50 { cms.insert(b"x"); }
393        assert!(cms.estimate_count(b"x") >= 50);
394        cms.reset();
395        assert_eq!(cms.estimate_count(b"x"), 0);
396        assert_eq!(cms.total_inserts(), 0);
397        std::fs::remove_file(&p).ok();
398    }
399
400    #[test]
401    fn cross_handle_visibility() {
402        let p = tmp("cross-handle");
403        let w = SharedCountMinSketch::create(&p, 4, 256).unwrap();
404        let r = SharedCountMinSketch::open(&p, 4, 256).unwrap();
405        w.insert(b"shared");
406        w.insert(b"shared");
407        assert!(r.estimate_count(b"shared") >= 2);
408        assert_eq!(r.total_inserts(), 2);
409        std::fs::remove_file(&p).ok();
410    }
411
412    #[test]
413    fn config_mismatch_at_open_rejected() {
414        let p = tmp("mismatch");
415        let _w = SharedCountMinSketch::create(&p, 4, 256).unwrap();
416        assert!(matches!(
417            SharedCountMinSketch::open(&p, 5, 256),
418            Err(CMSError::LayoutMismatch)
419        ));
420        assert!(matches!(
421            SharedCountMinSketch::open(&p, 4, 512),
422            Err(CMSError::LayoutMismatch)
423        ));
424        std::fs::remove_file(&p).ok();
425    }
426
427    #[test]
428    fn suggest_config_returns_sensible_values() {
429        // epsilon=0.01, delta=0.01 -> w ~ e/0.01 ~ 272, d ~ ln(100) ~ 5.
430        let (d, w) = SharedCountMinSketch::suggest_config(0.01, 0.01);
431        assert!((250..=300).contains(&w));
432        assert!((4..=6).contains(&d));
433    }
434
435    #[test]
436    fn concurrent_inserters_accurate() {
437        // 4 threads each insert "shared-item" 1000 times. After
438        // join, estimate should be at least 4000.
439        let p = tmp("concurrent");
440        let cms = Arc::new(SharedCountMinSketch::create(&p, 4, 1024).unwrap());
441        let mut handles = vec![];
442        for _ in 0..4 {
443            let cms = cms.clone();
444            handles.push(thread::spawn(move || {
445                for _ in 0..1000 { cms.insert(b"shared-item"); }
446            }));
447        }
448        for h in handles { h.join().unwrap(); }
449        let est = cms.estimate_count(b"shared-item");
450        assert!(est >= 4000, "concurrent estimate {est} should be >= 4000");
451        assert_eq!(cms.total_inserts(), 4000);
452        std::fs::remove_file(&p).ok();
453    }
454
455    #[test]
456    fn disk_persistence_survives_reopen() {
457        let p = tmp("disk");
458        {
459            let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
460            for _ in 0..50 { cms.insert(b"persisted"); }
461            cms.flush().unwrap();
462        }
463        let cms2 = SharedCountMinSketch::open(&p, 4, 256).unwrap();
464        assert!(cms2.estimate_count(b"persisted") >= 50);
465        assert_eq!(cms2.total_inserts(), 50);
466        std::fs::remove_file(&p).ok();
467    }
468}