Skip to main content

subms_hyperloglog/features/
sparse.rs

1//! Sparse HyperLogLog encoding for low-cardinality streams. Stores a
2//! `Vec<(register_index, rho)>` until the entry count crosses a
3//! configured threshold, then promotes itself into a dense
4//! `HyperLogLog` register array. Hot paths beyond promotion are the
5//! same as the base.
6//!
7//! Why this exists: a default p=14 HLL allocates 16 KB for the
8//! register array even when it has seen zero items. For pipelines
9//! that maintain millions of small sketches keyed by tenant /
10//! customer / shard, 16 KB per sketch quickly dominates memory. Sparse
11//! mode starts at zero payload and grows five bytes per distinct
12//! register touched, until the dense array stops being an
13//! over-allocation.
14//!
15//! Crossover threshold defaults to `m / 4` entries. Past that, the
16//! sparse list is past dense's memory cost without dense's O(1)
17//! lookup, so promotion is the right move. Promotion is one-way - we
18//! never go back from dense to sparse.
19//!
20//! This is the plain pair-list encoding, not HLL++'s. Heule et al.
21//! store the sparse pairs at a higher temporary precision and
22//! difference-encode them as varints behind a small unsorted temp set;
23//! that buys accuracy and bytes at low cardinality and costs a merge
24//! step on every flush. Neither is implemented here.
25
26use crate::{HllError, HyperLogLog, alpha_m, fnv1a64};
27
28#[cfg(feature = "serde")]
29use serde::{Deserialize, Serialize};
30
31/// HyperLogLog variant that holds a compact `(idx, rho)` pair list at
32/// low cardinality and promotes to a dense register array once the
33/// list grows past a threshold.
34///
35/// Single-writer, same as the base type: `add`, `merge`, `clear` and
36/// `promote` take `&mut self`.
37#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
38#[derive(Clone)]
39pub struct SparseHyperLogLog {
40    p: u32,
41    m: u32,
42    alpha: f64,
43    /// `None` once we've promoted to dense.
44    sparse: Option<Vec<(u32, u8)>>,
45    /// Populated after promotion; `None` while sparse.
46    dense: Option<HyperLogLog>,
47    /// Promotion threshold: number of distinct register indices
48    /// allowed in the sparse representation before we materialise the
49    /// full register array.
50    threshold: usize,
51}
52
53impl core::fmt::Debug for SparseHyperLogLog {
54    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
55        f.debug_struct("SparseHyperLogLog")
56            .field("p", &self.p)
57            .field("sparse", &self.is_sparse())
58            .field("entries", &self.entry_count())
59            .field("estimate", &self.estimate())
60            .finish()
61    }
62}
63
64impl SparseHyperLogLog {
65    /// New empty sparse-mode HLL at precision `p` (clamped to [4, 18])
66    /// and default threshold `m / 4`.
67    ///
68    /// `m/4` is a round heuristic and it overshoots: at five bytes an entry
69    /// the pair list reaches the dense array's byte cost at `m/5`, so between
70    /// `m/5` and `m/4` a sparse sketch is both bigger and slower to probe.
71    /// Pass `with_threshold(p, m / 5)` when bytes are what you are buying.
72    pub fn new(precision: u32) -> Self {
73        let p = precision.clamp(crate::MIN_PRECISION, crate::MAX_PRECISION);
74        let m = 1u32 << p;
75        let threshold = (m / 4) as usize;
76        Self::with_threshold(p, threshold)
77    }
78
79    /// Explicit promotion threshold (in distinct register entries).
80    /// Use this when you know the workload's cardinality envelope and
81    /// want to delay or hasten the dense crossover.
82    pub fn with_threshold(precision: u32, threshold: usize) -> Self {
83        let p = precision.clamp(crate::MIN_PRECISION, crate::MAX_PRECISION);
84        let m = 1u32 << p;
85        let alpha = alpha_m(m);
86        Self {
87            p,
88            m,
89            alpha,
90            sparse: Some(Vec::new()),
91            dense: None,
92            threshold: threshold.max(1),
93        }
94    }
95
96    /// Wraps an already-dense sketch. Used by the wire codec when a buffer
97    /// was written after promotion.
98    pub(crate) fn from_dense(dense: HyperLogLog) -> Self {
99        let p = dense.precision();
100        let m = dense.register_count();
101        Self {
102            p,
103            m,
104            alpha: alpha_m(m),
105            sparse: None,
106            dense: Some(dense),
107            threshold: (m / 4) as usize,
108        }
109    }
110
111    pub fn precision(&self) -> u32 {
112        self.p
113    }
114    pub fn register_count(&self) -> u32 {
115        self.m
116    }
117    pub fn is_sparse(&self) -> bool {
118        self.sparse.is_some()
119    }
120    /// Entry count at which this sketch promotes to dense.
121    pub fn threshold(&self) -> usize {
122        self.threshold
123    }
124
125    /// Analytic relative standard error once dense, `1.04 / sqrt(m)`. Sparse
126    /// mode is tighter than this because linear counting over a mostly-empty
127    /// register space is the accurate estimator down there; the number is the
128    /// envelope the sketch converges to, not a bound on its current state.
129    pub fn standard_error(&self) -> f64 {
130        crate::RSE_CONSTANT / (self.m as f64).sqrt()
131    }
132
133    /// Payload cost of the representation, register array or pair list. This
134    /// is the number the feature exists to move: at p=14 an untouched sparse
135    /// sketch is a fraction of the dense 16384 bytes.
136    ///
137    /// Five bytes per entry, matching the wire encoding rather than the
138    /// allocator - a `Vec<(u32, u8)>` pads each pair to eight and Java's two
139    /// parallel arrays do not, so a layout-exact number would disagree across
140    /// the ports for no useful reason.
141    pub fn state_bytes(&self) -> usize {
142        match (&self.sparse, &self.dense) {
143            (Some(list), _) => list.len() * 5,
144            (None, Some(d)) => d.state_bytes(),
145            _ => 0,
146        }
147    }
148
149    /// True while nothing has been recorded.
150    pub fn is_empty(&self) -> bool {
151        match (&self.sparse, &self.dense) {
152            (Some(list), _) => list.is_empty(),
153            (None, Some(d)) => d.is_empty(),
154            _ => true,
155        }
156    }
157
158    /// Reset to an empty sparse sketch, dropping the dense array if we had
159    /// promoted. The threshold survives; a reused sketch keeps its sizing.
160    pub fn clear(&mut self) {
161        self.sparse = Some(Vec::new());
162        self.dense = None;
163    }
164
165    /// Distinct register entries currently held. Once promoted to
166    /// dense the answer is the count of non-zero registers.
167    pub fn entry_count(&self) -> usize {
168        if let Some(list) = &self.sparse {
169            list.len()
170        } else if let Some(d) = &self.dense {
171            d.registers().iter().filter(|&&r| r != 0).count()
172        } else {
173            0
174        }
175    }
176
177    /// Record a key. Returns true when the sketch changed. If we're sparse and
178    /// the new entry pushes us past the threshold, promote to dense before
179    /// returning.
180    pub fn add(&mut self, key: &str) -> bool {
181        self.add_bytes(key.as_bytes())
182    }
183
184    /// Record a 64-bit id without rendering it to a string.
185    pub fn add_u64(&mut self, key: u64) -> bool {
186        self.add_bytes(&key.to_be_bytes())
187    }
188
189    /// Record raw bytes.
190    pub fn add_bytes(&mut self, key: &[u8]) -> bool {
191        let h = fnv1a64(key);
192        let idx = (h >> (64 - self.p)) as u32;
193        let w = (h << self.p) | (1u64 << (self.p - 1));
194        let r = (w.leading_zeros() + 1) as u8;
195        if let Some(list) = self.sparse.as_mut() {
196            // Linear-probe the sparse list. With list lengths bounded
197            // by `threshold = m/4`, this is bounded work; at p=14
198            // that's at most ~4k cells - well below the cost of the
199            // dense array allocation we're trying to avoid.
200            if let Some(pos) = list.iter().position(|(i, _)| *i == idx) {
201                if r > list[pos].1 {
202                    list[pos].1 = r;
203                    return true;
204                }
205                false
206            } else {
207                list.push((idx, r));
208                if list.len() >= self.threshold {
209                    self.promote();
210                }
211                true
212            }
213        } else if let Some(d) = self.dense.as_mut() {
214            d.add_bytes(key)
215        } else {
216            false
217        }
218    }
219
220    /// Estimate distinct count. In sparse mode the registers we don't
221    /// hold are zero, so linear counting is exact under the HLL
222    /// assumption that absent registers contribute log term `-m * ln(1)
223    /// = 0`. We use the base HLL formula uniformly for consistency.
224    pub fn estimate(&self) -> f64 {
225        if let Some(list) = &self.sparse {
226            let m = self.m as f64;
227            // sum(2^-r_i) = sum over held entries + (m - len) * 2^0 for zero registers
228            let held_sum: f64 = list.iter().map(|(_, r)| 2f64.powi(-(*r as i32))).sum();
229            let zero_count = self.m as usize - list.len();
230            let sum = held_sum + zero_count as f64;
231            let raw = self.alpha * m * m / sum;
232            if zero_count > 0 && raw <= 2.5 * m {
233                -m * (zero_count as f64 / m).ln()
234            } else {
235                raw
236            }
237        } else if let Some(d) = &self.dense {
238            d.estimate()
239        } else {
240            0.0
241        }
242    }
243
244    /// Merge another sparse sketch of the same precision. Two sparse lists
245    /// combine entry-wise and may cross the threshold on the way, in which
246    /// case the result promotes. Once either side is dense the merge runs on
247    /// dense registers, which is where a fan-in of many shards ends up.
248    pub fn merge(&mut self, other: &Self) -> Result<(), HllError> {
249        if self.p != other.p {
250            return Err(HllError::PrecisionMismatch {
251                left: self.p,
252                right: other.p,
253            });
254        }
255        if other.dense.is_some() {
256            self.promote();
257        }
258        if let Some(list) = self.sparse.as_mut() {
259            let entries = other.sparse.as_ref().expect("other is sparse here");
260            for &(idx, r) in entries {
261                match list.iter().position(|(i, _)| *i == idx) {
262                    Some(pos) => {
263                        if r > list[pos].1 {
264                            list[pos].1 = r;
265                        }
266                    }
267                    None => list.push((idx, r)),
268                }
269            }
270            if list.len() >= self.threshold {
271                self.promote();
272            }
273            return Ok(());
274        }
275        let target = self.dense.as_mut().expect("promoted above");
276        match &other.dense {
277            Some(d) => target.merge(d),
278            None => {
279                let entries = other.sparse.as_ref().expect("sparse when not dense");
280                target.apply_sparse(entries);
281                Ok(())
282            }
283        }
284    }
285
286    /// Force promotion to dense even if below the threshold. Useful
287    /// for benchmarking or for handing the inner dense HLL to a peer
288    /// that does not understand sparse mode.
289    pub fn promote(&mut self) {
290        if self.dense.is_some() {
291            return;
292        }
293        let list = self.sparse.take().unwrap_or_default();
294        let mut dense = HyperLogLog::new(self.p);
295        // Mutating registers needs a writable view; expose via a
296        // dedicated promotion helper on the base.
297        dense.apply_sparse(&list);
298        self.dense = Some(dense);
299    }
300
301    /// View into the dense HLL after promotion. `None` while sparse.
302    pub fn as_dense(&self) -> Option<&HyperLogLog> {
303        self.dense.as_ref()
304    }
305
306    /// Materialise a dense copy without mutating this sketch. The bridge to
307    /// `estimate_union` / `estimate_intersect`, which only take base sketches.
308    pub fn to_dense(&self) -> HyperLogLog {
309        match &self.dense {
310            Some(d) => {
311                let mut out = HyperLogLog::new(self.p);
312                let _ = out.merge(d);
313                out
314            }
315            None => {
316                let mut out = HyperLogLog::new(self.p);
317                if let Some(list) = &self.sparse {
318                    out.apply_sparse(list);
319                }
320                out
321            }
322        }
323    }
324
325    pub(crate) fn entries(&self) -> Option<&[(u32, u8)]> {
326        self.sparse.as_deref()
327    }
328}
329
330// Tiny extension on the base type so the sparse promoter can seed
331// register values without making the registers public globally.
332impl HyperLogLog {
333    /// Apply a sparse list of `(register_index, rho)` pairs to a
334    /// fresh dense register array. Used by `SparseHyperLogLog::promote`.
335    pub(crate) fn apply_sparse(&mut self, list: &[(u32, u8)]) {
336        // Direct field access is fine inside the impl - the field is
337        // private to the crate, this is the only writer outside the
338        // base `add()` method.
339        for &(idx, r) in list {
340            let i = idx as usize;
341            if i < self.registers.len() && r > self.registers[i] {
342                self.registers[i] = r;
343            }
344        }
345    }
346}
347
348mod wire {
349    use super::SparseHyperLogLog;
350    use crate::HllError;
351    use crate::codec::{ENC_DENSE, ENC_SPARSE, HEADER_LEN, read_header, read_u32, write_header};
352
353    impl SparseHyperLogLog {
354        /// Serialise in whichever representation the sketch currently holds.
355        /// A thin sketch stays thin on the wire; a promoted one writes the
356        /// same dense buffer `HyperLogLog::to_bytes` would.
357        pub fn to_bytes(&self) -> Vec<u8> {
358            if let Some(d) = self.as_dense() {
359                return d.to_bytes();
360            }
361            let entries = self.entries().unwrap_or(&[]);
362            let mut out = Vec::with_capacity(HEADER_LEN + 8 + entries.len() * 5);
363            write_header(&mut out, ENC_SPARSE, self.precision());
364            out.extend_from_slice(&(self.threshold() as u32).to_be_bytes());
365            out.extend_from_slice(&(entries.len() as u32).to_be_bytes());
366            for &(idx, r) in entries {
367                out.extend_from_slice(&idx.to_be_bytes());
368                out.push(r);
369            }
370            out
371        }
372
373        /// Parse either encoding. A dense buffer comes back as an
374        /// already-promoted sketch, which is the honest reading: the writer
375        /// had crossed the threshold and the reader inherits that.
376        pub fn from_bytes(bytes: &[u8]) -> Result<Self, HllError> {
377            let (encoding, p) = read_header(bytes)?;
378            if encoding == ENC_DENSE {
379                return Ok(Self::from_dense(crate::HyperLogLog::from_bytes(bytes)?));
380            }
381            if encoding != ENC_SPARSE {
382                return Err(HllError::UnsupportedEncoding(encoding));
383            }
384            if bytes.len() < HEADER_LEN + 8 {
385                return Err(HllError::Truncated {
386                    expected: HEADER_LEN + 8,
387                    actual: bytes.len(),
388                });
389            }
390            let threshold = read_u32(bytes, HEADER_LEN) as usize;
391            let count = read_u32(bytes, HEADER_LEN + 4) as usize;
392            let expected = HEADER_LEN + 8 + count * 5;
393            if bytes.len() < expected {
394                return Err(HllError::Truncated {
395                    expected,
396                    actual: bytes.len(),
397                });
398            }
399            let mut out = Self::with_threshold(p, threshold);
400            let list = out.sparse.as_mut().expect("fresh sketch is sparse");
401            for i in 0..count {
402                let at = HEADER_LEN + 8 + i * 5;
403                list.push((read_u32(bytes, at), bytes[at + 4]));
404            }
405            if list.len() >= out.threshold {
406                out.promote();
407            }
408            Ok(out)
409        }
410    }
411}
412
413#[cfg(test)]
414#[path = "sparse_tests.rs"]
415mod tests;