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;