Skip to main content

subms_hyperloglog/
lib.rs

1//! HyperLogLog cardinality estimator.
2//!
3//! Precision `p` in [4, 18]. The register array is `m = 2^p` bytes. Hashes
4//! split into a `p`-bit register index (top bits) and a leading-zero count on
5//! the remaining bits. Each register stores the max observed count + 1.
6//! Estimate is the harmonic mean of `2^r` over the registers, scaled by an
7//! `alpha_m` correction. Linear-counting kicks in at low cardinality where
8//! the raw estimator is biased.
9//!
10//! ```
11//! use subms_hyperloglog::HyperLogLog;
12//! let mut hll = HyperLogLog::new(14);
13//! for i in 0..10_000 { hll.add(&format!("key{i}")); }
14//! let est = hll.estimate();
15//! assert!(est > 9_000.0 && est < 11_000.0, "10k distinct within 10%, got {est}");
16//! ```
17//!
18//! # Thread safety
19//!
20//! A `HyperLogLog` is a single-writer structure. `add`, `merge` and `clear`
21//! take `&mut self`, so the compiler already stops two threads sharing one
22//! sketch without a lock. The fan-in pattern is a sketch per thread or shard
23//! and one `merge` at read time; the merge is exact, so nothing is lost by
24//! never sharing a writer. `estimate` takes `&self` and is safe to call
25//! concurrently on a sketch nobody is writing.
26//!
27//! Full writeup, design notes and measured benchmarks:
28//! <https://www.submillisecond.com/cookbook/recipes/subms-hyperloglog>
29
30pub(crate) const FNV_OFFSET: u64 = 0xcbf29ce484222325;
31pub(crate) const FNV_PRIME: u64 = 0x100000001b3;
32
33/// Lowest precision the estimator is calibrated for.
34pub const MIN_PRECISION: u32 = 4;
35/// Highest precision this recipe allocates for. 2^18 registers is 256 KB.
36pub const MAX_PRECISION: u32 = 18;
37
38/// Flajolet's asymptotic relative standard error constant. Standard error is
39/// `RSE_CONSTANT / sqrt(m)`.
40pub const RSE_CONSTANT: f64 = 1.04;
41
42mod codec;
43mod error;
44pub use codec::{FORMAT_VERSION, MAGIC};
45pub use error::HllError;
46
47#[cfg(feature = "serde")]
48use serde::{Deserialize, Serialize};
49
50#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
51#[derive(Clone, PartialEq)]
52pub struct HyperLogLog {
53    p: u32,
54    m: u32,
55    pub(crate) registers: Vec<u8>,
56    alpha: f64,
57}
58
59impl core::fmt::Debug for HyperLogLog {
60    /// Deliberately does not dump the register array - at p=14 that is 16384
61    /// bytes into whatever log caught the assertion.
62    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63        f.debug_struct("HyperLogLog")
64            .field("p", &self.p)
65            .field("m", &self.m)
66            .field("estimate", &self.estimate())
67            .finish()
68    }
69}
70
71impl HyperLogLog {
72    /// New empty HLL at the given precision. `precision` is clamped to
73    /// `[4, 18]`; 14 gives ~16k registers / ~16 KB / ~1% std error. Use
74    /// [`HyperLogLog::try_new`] when a caller-supplied precision should be
75    /// rejected rather than silently pulled into range.
76    pub fn new(precision: u32) -> Self {
77        let p = precision.clamp(MIN_PRECISION, MAX_PRECISION);
78        let m = 1u32 << p;
79        let alpha = alpha_m(m);
80        Self {
81            p,
82            m,
83            registers: vec![0u8; m as usize],
84            alpha,
85        }
86    }
87
88    /// New empty HLL, rejecting a precision outside `[4, 18]` instead of
89    /// clamping it. Reach for this when the precision comes from config or a
90    /// wire message and a typo should fail loudly.
91    pub fn try_new(precision: u32) -> Result<Self, HllError> {
92        if !(MIN_PRECISION..=MAX_PRECISION).contains(&precision) {
93            return Err(HllError::InvalidPrecision(precision));
94        }
95        Ok(Self::new(precision))
96    }
97
98    pub fn precision(&self) -> u32 {
99        self.p
100    }
101    pub fn register_count(&self) -> u32 {
102        self.m
103    }
104
105    /// Analytic relative standard error, `1.04 / sqrt(m)`. This is the error
106    /// the structure carries by construction, not a measurement of the current
107    /// contents: at p=14 it is 0.813%, so a 1,000,000 estimate is one standard
108    /// deviation away from anything in [992k, 1008k].
109    pub fn standard_error(&self) -> f64 {
110        RSE_CONSTANT / (self.m as f64).sqrt()
111    }
112
113    /// Smallest precision whose standard error is at or below `target`
114    /// (expressed as a fraction, so 0.01 for 1%). Clamped to `[4, 18]`, so a
115    /// target finer than 0.26% returns 18 and the caller gets the best this
116    /// recipe allocates for rather than an error.
117    pub fn precision_for_standard_error(target: f64) -> u32 {
118        for p in MIN_PRECISION..MAX_PRECISION {
119            let m = (1u32 << p) as f64;
120            if RSE_CONSTANT / m.sqrt() <= target {
121                return p;
122            }
123        }
124        MAX_PRECISION
125    }
126
127    /// Bytes of register state this sketch holds. Fixed at construction and
128    /// independent of how many items it has seen.
129    pub fn state_bytes(&self) -> usize {
130        self.registers.len()
131    }
132
133    /// True while every register is still zero.
134    pub fn is_empty(&self) -> bool {
135        self.registers.iter().all(|&r| r == 0)
136    }
137
138    /// Zero every register, keeping the allocation. Reuse across windows
139    /// without re-allocating the array.
140    pub fn clear(&mut self) {
141        self.registers.fill(0);
142    }
143
144    /// Record a key. Returns true when the sketch changed - a register moved
145    /// up, so this key was the first of its kind to land that deep. Matching
146    /// `PFADD`'s return, and cheap enough to ignore when you do not want it.
147    pub fn add(&mut self, key: &str) -> bool {
148        self.add_bytes(key.as_bytes())
149    }
150
151    /// Record raw bytes. The string path funnels through here, so `add("AAPL")`
152    /// and `add_bytes(b"AAPL")` land in the same register.
153    pub fn add_bytes(&mut self, key: &[u8]) -> bool {
154        self.add_hash(fnv1a64(key))
155    }
156
157    /// Record a 64-bit id without rendering it to a string first. Hashes the
158    /// big-endian bytes, so the Rust and Java ports agree register for
159    /// register on the same id.
160    pub fn add_u64(&mut self, key: u64) -> bool {
161        self.add_bytes(&key.to_be_bytes())
162    }
163
164    fn add_hash(&mut self, h: u64) -> bool {
165        let idx = (h >> (64 - self.p)) as usize;
166        // Use the remaining 64-p bits for the leading-zero count. Place a
167        // sentinel 1 at the bottom so leading_zeros never exceeds (64-p).
168        let w = (h << self.p) | (1u64 << (self.p - 1));
169        let r = (w.leading_zeros() + 1) as u8;
170        if r > self.registers[idx] {
171            self.registers[idx] = r;
172            true
173        } else {
174            false
175        }
176    }
177
178    /// Estimate distinct count.
179    pub fn estimate(&self) -> f64 {
180        let m = self.m as f64;
181        // Sum 2^-r_i, harmonic-style.
182        let sum: f64 = self.registers.iter().map(|&r| 2f64.powi(-(r as i32))).sum();
183        let raw = self.alpha * m * m / sum;
184
185        // Linear counting at low cardinality. Threshold per Flajolet et al.
186        let zeros = self.registers.iter().filter(|&&r| r == 0).count();
187        if zeros > 0 && raw <= 2.5 * m {
188            -m * (zeros as f64 / m).ln()
189        } else {
190            raw
191        }
192    }
193
194    /// Merge another HLL of the same precision. Element-wise max over registers.
195    pub fn merge(&mut self, other: &Self) -> Result<(), HllError> {
196        if self.p != other.p {
197            return Err(HllError::PrecisionMismatch {
198                left: self.p,
199                right: other.p,
200            });
201        }
202        for (a, b) in self.registers.iter_mut().zip(other.registers.iter()) {
203            if *b > *a {
204                *a = *b;
205            }
206        }
207        Ok(())
208    }
209}
210
211impl HyperLogLog {
212    /// Access the underlying register array. Used by the feature
213    /// modules (sparse promotion, union/intersect) without making the
214    /// field itself public.
215    #[inline]
216    #[allow(dead_code)] // used only by the feature modules (off under default features)
217    pub(crate) fn registers(&self) -> &[u8] {
218        &self.registers
219    }
220}
221
222pub(crate) fn alpha_m(m: u32) -> f64 {
223    match m {
224        16 => 0.673,
225        32 => 0.697,
226        64 => 0.709,
227        _ => 0.7213 / (1.0 + 1.079 / m as f64),
228    }
229}
230
231pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 {
232    let mut h = FNV_OFFSET;
233    for &b in bytes {
234        h ^= b as u64;
235        h = h.wrapping_mul(FNV_PRIME);
236    }
237    // FNV-1a's bit distribution is poor for short sequential keys; pipe
238    // through a SplitMix64 finalizer so HLL's bucket index and leading-zero
239    // extraction see a well-mixed value.
240    h ^= h >> 30;
241    h = h.wrapping_mul(0xbf58476d1ce4e5b9);
242    h ^= h >> 27;
243    h = h.wrapping_mul(0x94d049bb133111eb);
244    h ^= h >> 31;
245    h
246}
247
248#[cfg(feature = "harness")]
249pub mod recipe;
250
251// Opt-in feature modules. Base HLL is zero-dep + std-only; each opt-in
252// adds a focused capability under its own Cargo feature.
253#[cfg(any(feature = "sparse", feature = "union-intersect"))]
254pub mod features;
255
256#[cfg(feature = "sparse")]
257pub use features::sparse::SparseHyperLogLog;
258#[cfg(feature = "union-intersect")]
259pub use features::union_intersect::{estimate_intersect, estimate_union, intersect_error_bound};
260
261#[cfg(test)]
262#[path = "hll_tests.rs"]
263mod hll_tests;
264
265#[cfg(test)]
266#[path = "sample_app_tests.rs"]
267mod sample_app_tests;