Skip to main content

rudb_encoding/
sketch.rs

1//! Counting distinct values, and deciding whether two columns are related, without holding either
2//! column in memory.
3//!
4//! Every decision in `spec/06-compression.md` section 6.4 and 6.5 starts with a question about a
5//! column that is too big to answer exactly. Is this column worth a dictionary, which is a distinct
6//! count. Do these two columns come from the same universe, which is an overlap between two value
7//! sets. Is this column determined by that one, which is whether the pairs have as many distinct
8//! values as the left side alone. Section 6.4 also says the pair space has to be pruned, because
9//! 105 columns is 5,460 pairs and testing all of them exactly is not something a load can do.
10//!
11//! A bottom-k sketch answers all three from one pass per column and a fixed amount of memory.
12//!
13//! ## What it is
14//!
15//! Hash every value and keep the k smallest distinct hashes. That set is a uniform random sample of
16//! the column's distinct values, chosen by a rule that does not depend on the order they arrived
17//! in, so two sketches built on different machines from the same values are identical.
18//!
19//! The distinct count comes out of where the k smallest hashes end. If the hashes are uniform over
20//! the 64 bit range, then after seeing `d` distinct values the kth smallest sits at about `k / d`
21//! of the way through the range, so `d` is about `k` divided by that fraction. The standard
22//! correction uses `k - 1` rather than `k`, which is what makes the estimate unbiased rather than
23//! merely close. Relative error is about one over the square root of k, so the default k of 4096
24//! is a bit under 2 percent, and a sketch that never filled up is not an estimate at all because
25//! then it holds every distinct hash there was.
26//!
27//! The overlap between two columns comes from merging the two sketches and asking how many of the
28//! k smallest hashes of the union are in both. That is the Jaccard similarity, and the reason it
29//! works on sketches is that any hash small enough to be in the union's bottom k is small enough
30//! that if it were in a column at all it would be in that column's own bottom k. So a lookup in the
31//! sketch is a lookup in the column.
32//!
33//! ## Why not HyperLogLog
34//!
35//! Section 6.5 says HyperLogLog for the distinct count and that is the right structure if counting
36//! is all you want, because it answers in a kilobyte where this wants tens. It cannot do the other
37//! two questions. A HyperLogLog register holds a leading zero count and not a value, so two
38//! HyperLogLogs can be merged into a count of the union but they cannot tell you which values the
39//! union kept, and the intersection they give by inclusion and exclusion is the difference of three
40//! noisy numbers, which for two columns that barely overlap is noise. The sketch here keeps actual
41//! hashes, so an intersection is a set intersection and the error on it is the error on the sample
42//! rather than the error on the difference. 32 KB per column at the default k, for 105 columns, is
43//! 3 MB for a whole table, and the pair pruning it buys is worth more than the 3 MB.
44//!
45//! ## The hash
46//!
47//! Values are hashed with a multiply and fold over 8 byte words. This is a sketching hash and not a
48//! persisted one: nothing on disk depends on it, so it can be replaced with something faster
49//! without a format version. What it does have to be is uniform, because every estimate here
50//! assumes it is, and the tests measure that rather than asserting it.
51
52use rudb_common::{Error, Result};
53
54/// The default number of hashes to keep, which puts the relative error a bit under 2 percent.
55pub const DEFAULT_K: usize = 4096;
56
57/// A bottom-k sketch of the distinct values of a column.
58///
59/// The retained hashes are sorted and deduplicated, so the sketch is a function of the set of
60/// values and not of the order they were added in.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Sketch {
63    k: usize,
64    hashes: Vec<u64>,
65}
66
67impl Sketch {
68    /// An empty sketch that will keep the `k` smallest hashes.
69    ///
70    /// # Errors
71    ///
72    /// If `k` is zero, which would make every estimate a division by nothing.
73    pub fn new(k: usize) -> Result<Self> {
74        if k == 0 {
75            return Err(Error::internal("a sketch that keeps no hashes estimates nothing"));
76        }
77        Ok(Self { k, hashes: Vec::new() })
78    }
79
80    /// A sketch over a column, at the default k.
81    #[must_use]
82    pub fn of(values: &[&[u8]]) -> Self {
83        let mut sketch = Self { k: DEFAULT_K, hashes: Vec::new() };
84        for value in values {
85            sketch.add(value);
86        }
87        sketch
88    }
89
90    /// Adds a value.
91    pub fn add(&mut self, value: &[u8]) {
92        self.add_hash(hash64(value));
93    }
94
95    /// Adds a value that has already been hashed, for a caller that is hashing anyway.
96    pub fn add_hash(&mut self, hash: u64) {
97        // The common case once the sketch is full. A column of a hundred million values takes this
98        // branch for all but a few thousand of them, so everything below it is off the hot path.
99        if self.hashes.len() == self.k {
100            match self.hashes.last() {
101                Some(largest) if hash >= *largest => return,
102                _ => {}
103            }
104        }
105        match self.hashes.binary_search(&hash) {
106            Ok(_) => {}
107            Err(at) => {
108                self.hashes.insert(at, hash);
109                self.hashes.truncate(self.k);
110            }
111        }
112    }
113
114    /// How many hashes the sketch is holding.
115    #[must_use]
116    pub fn len(&self) -> usize {
117        self.hashes.len()
118    }
119
120    /// Whether nothing has been added.
121    #[must_use]
122    pub fn is_empty(&self) -> bool {
123        self.hashes.is_empty()
124    }
125
126    /// Whether the sketch saw at most k distinct values, in which case it holds all of them and
127    /// every count it gives is exact rather than estimated.
128    #[must_use]
129    pub fn is_exact(&self) -> bool {
130        self.hashes.len() < self.k
131    }
132
133    /// The estimated number of distinct values, which is the exact number when [`Sketch::is_exact`]
134    /// holds.
135    #[must_use]
136    pub fn distinct(&self) -> f64 {
137        if self.is_exact() {
138            return self.hashes.len() as f64;
139        }
140        let largest = self.hashes[self.hashes.len() - 1] as f64 / u64::MAX as f64;
141        if largest <= 0.0 {
142            return self.hashes.len() as f64;
143        }
144        (self.k as f64 - 1.0) / largest
145    }
146
147    /// The union of two sketches, which is the sketch the union of the two columns would have
148    /// produced.
149    ///
150    /// # Errors
151    ///
152    /// If the two sketches keep a different number of hashes, because then neither one's threshold
153    /// applies to the other and no estimate over the pair means anything.
154    pub fn union(&self, other: &Self) -> Result<Self> {
155        if self.k != other.k {
156            return Err(Error::internal(format!(
157                "sketches of {} and {} hashes cannot be combined",
158                self.k, other.k
159            )));
160        }
161        let mut merged = Self { k: self.k, hashes: Vec::with_capacity(self.k) };
162        let mut left = self.hashes.iter().peekable();
163        let mut right = other.hashes.iter().peekable();
164        while merged.hashes.len() < self.k {
165            let next = match (left.peek(), right.peek()) {
166                (Some(a), Some(b)) => {
167                    if a <= b {
168                        left.next()
169                    } else {
170                        right.next()
171                    }
172                }
173                (Some(_), None) => left.next(),
174                (None, Some(_)) => right.next(),
175                (None, None) => break,
176            };
177            let Some(hash) = next else {
178                break;
179            };
180            if merged.hashes.last() != Some(hash) {
181                merged.hashes.push(*hash);
182            }
183        }
184        Ok(merged)
185    }
186
187    /// The estimated Jaccard similarity, which is the size of the intersection of the two value
188    /// sets over the size of their union.
189    ///
190    /// Section 6.4 wants this to decide whether two columns are drawn from the same universe and
191    /// should share a dictionary. It is not a decision on its own, because two columns can overlap
192    /// heavily and still be better off apart if one of them is tiny, but it is what prunes 5,460
193    /// pairs down to the handful worth measuring properly.
194    ///
195    /// # Errors
196    ///
197    /// As [`Sketch::union`].
198    pub fn jaccard(&self, other: &Self) -> Result<f64> {
199        let union = self.union(other)?;
200        if union.is_empty() {
201            return Ok(0.0);
202        }
203        let both =
204            union.hashes.iter().filter(|hash| self.holds(**hash) && other.holds(**hash)).count();
205        Ok(both as f64 / union.hashes.len() as f64)
206    }
207
208    /// Whether a hash is in the sketch. Only meaningful for a hash that is small enough to have
209    /// been kept if it were present, which is what [`Sketch::jaccard`] guarantees by taking its
210    /// candidates from the union.
211    fn holds(&self, hash: u64) -> bool {
212        self.hashes.binary_search(&hash).is_ok()
213    }
214}
215
216/// How close a column is to being determined by another one, from a sketch of the left column and
217/// a sketch of the two of them paired.
218///
219/// A functional dependency from A to B means every A value goes with exactly one B value, so the
220/// pairs have exactly as many distinct values as A does. On ClickBench `hits` this is `URLHash`
221/// against `URL` and `RefererHash` against `Referer`, which section 6.6 says is 1.6 GB of `BIGINT`
222/// carrying nothing that is not already in two string columns.
223///
224/// The result is 1.0 for a dependency that holds and drops towards the ratio of the two counts as
225/// it stops holding. It is an estimate over two estimates, so a value near 1.0 is a candidate to be
226/// verified exactly and never a conclusion. Section 6.6 is explicit that a rule is applied only
227/// after a full verification pass, and this is what decides which pairs are worth that pass.
228///
229/// # Errors
230///
231/// As [`Sketch::union`], and if the pairs somehow have fewer distinct values than the left column,
232/// which cannot happen and is a bug in the caller's pairing if it does.
233pub fn dependence(left: &Sketch, pairs: &Sketch) -> Result<f64> {
234    if left.k != pairs.k {
235        return Err(Error::internal("a column and its pairs need sketches of the same size"));
236    }
237    let alone = left.distinct();
238    let together = pairs.distinct();
239    if alone <= 0.0 {
240        return Ok(1.0);
241    }
242    Ok((alone / together.max(alone)).min(1.0))
243}
244
245/// The hash of two values as a pair, for [`dependence`].
246///
247/// The left hash is mixed before the two are combined, so that the pair of `ab` and `c` does not
248/// hash the same as the pair of `a` and `bc`.
249#[must_use]
250pub fn pair_hash(left: &[u8], right: &[u8]) -> u64 {
251    mix(hash64(left) ^ SEEDS[3], hash64(right).wrapping_add(SEEDS[2]))
252}
253
254/// The constants are odd 64 bit values with about half their bits set, which is what a multiply
255/// based mixer needs to move low bits into high ones.
256const SEEDS: [u64; 4] =
257    [0xa076_1d64_78bd_642f, 0xe703_7ed1_a0b4_28db, 0x8ebc_6af0_9c88_c6e3, 0x5899_65cc_7537_4cc3];
258
259/// A 64 bit multiply of two values, folded to 64 bits by xoring the halves.
260///
261/// This is the whole strength of the hash. A 64 by 64 multiply moves every input bit into the high
262/// half of the product, and xoring the halves together brings them back down, so one of these turns
263/// a one bit change anywhere into a change in about half the output bits.
264fn mix(left: u64, right: u64) -> u64 {
265    let wide = u128::from(left).wrapping_mul(u128::from(right));
266    (wide as u64) ^ ((wide >> 64) as u64)
267}
268
269/// The hash used by every sketch here.
270///
271/// Nothing on disk depends on this, so it can be replaced with something faster without a format
272/// version. What it has to be is uniform, because every estimate in this module assumes the hashes
273/// are spread evenly over the range.
274#[must_use]
275pub fn hash64(value: &[u8]) -> u64 {
276    let mut state = SEEDS[0] ^ mix(value.len() as u64, SEEDS[1]);
277    let mut chunks = value.chunks_exact(8);
278    let mut word = [0u8; 8];
279    for chunk in &mut chunks {
280        word.copy_from_slice(chunk);
281        state = mix(state ^ u64::from_le_bytes(word), SEEDS[2]);
282    }
283    let rest = chunks.remainder();
284    if !rest.is_empty() {
285        let mut last = [0u8; 8];
286        last[..rest.len()].copy_from_slice(rest);
287        state = mix(state ^ u64::from_le_bytes(last), SEEDS[3]);
288    }
289    mix(state, SEEDS[1])
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn values(count: usize, prefix: &str) -> Vec<Vec<u8>> {
297        (0..count).map(|index| format!("{prefix}{index}").into_bytes()).collect()
298    }
299
300    fn borrow(values: &[Vec<u8>]) -> Vec<&[u8]> {
301        values.iter().map(Vec::as_slice).collect()
302    }
303
304    fn within(estimate: f64, actual: f64, tolerance: f64) -> bool {
305        (estimate - actual).abs() / actual <= tolerance
306    }
307
308    #[test]
309    fn a_sketch_that_never_filled_up_is_exact() {
310        let column = values(1000, "value-");
311        let sketch = Sketch::of(&borrow(&column));
312        assert!(sketch.is_exact());
313        assert_eq!(sketch.distinct(), 1000.0);
314    }
315
316    #[test]
317    fn duplicates_do_not_count() {
318        let mut sketch = Sketch::new(64).unwrap();
319        for _ in 0..1000 {
320            sketch.add(b"the same value");
321        }
322        assert_eq!(sketch.distinct(), 1.0);
323    }
324
325    #[test]
326    fn the_distinct_count_is_within_two_percent_at_the_default_k() {
327        for count in [50_000usize, 250_000, 1_000_000] {
328            let mut sketch = Sketch::new(DEFAULT_K).unwrap();
329            for index in 0..count {
330                sketch.add(format!("http://example.com/page/{index}").as_bytes());
331            }
332            assert!(!sketch.is_exact());
333            let estimate = sketch.distinct();
334            assert!(
335                within(estimate, count as f64, 0.02),
336                "{estimate:.0} against {count} distinct values"
337            );
338        }
339    }
340
341    #[test]
342    fn the_sketch_does_not_depend_on_the_order_values_arrived_in() {
343        let column = values(100_000, "value-");
344        let forwards = Sketch::of(&borrow(&column));
345        let mut backwards = Sketch::new(DEFAULT_K).unwrap();
346        for value in column.iter().rev() {
347            backwards.add(value);
348        }
349        assert_eq!(forwards, backwards);
350    }
351
352    #[test]
353    fn two_columns_with_the_same_values_overlap_completely() {
354        let column = values(200_000, "http://example.com/");
355        let left = Sketch::of(&borrow(&column));
356        let right = Sketch::of(&borrow(&column));
357        assert_eq!(left.jaccard(&right).unwrap(), 1.0);
358    }
359
360    #[test]
361    fn two_columns_with_nothing_in_common_do_not_overlap() {
362        let left = Sketch::of(&borrow(&values(200_000, "left-")));
363        let right = Sketch::of(&borrow(&values(200_000, "right-")));
364        assert_eq!(left.jaccard(&right).unwrap(), 0.0);
365    }
366
367    #[test]
368    fn a_half_overlap_measures_as_a_third() {
369        // Two columns of 100,000 values sharing 50,000 of them. The intersection is 50,000 and the
370        // union is 150,000, so the Jaccard similarity is a third and not a half, which is the
371        // number that catches people out about this measure.
372        let shared = values(50_000, "shared-");
373        let mut left = shared.clone();
374        left.extend(values(50_000, "left-"));
375        let mut right = shared;
376        right.extend(values(50_000, "right-"));
377        let overlap = Sketch::of(&borrow(&left)).jaccard(&Sketch::of(&borrow(&right))).unwrap();
378        assert!(within(overlap, 1.0 / 3.0, 0.05), "{overlap:.4}");
379    }
380
381    #[test]
382    fn the_union_of_two_sketches_counts_the_union_of_the_columns() {
383        let left = values(300_000, "left-");
384        let right = values(300_000, "right-");
385        let union = Sketch::of(&borrow(&left)).union(&Sketch::of(&borrow(&right))).unwrap();
386        assert!(within(union.distinct(), 600_000.0, 0.03), "{:.0}", union.distinct());
387    }
388
389    #[test]
390    fn sketches_of_different_sizes_do_not_combine() {
391        let small = Sketch::new(16).unwrap();
392        let large = Sketch::new(32).unwrap();
393        assert!(small.union(&large).is_err());
394        assert!(small.jaccard(&large).is_err());
395    }
396
397    #[test]
398    fn a_sketch_that_keeps_nothing_is_rejected() {
399        assert!(Sketch::new(0).is_err());
400    }
401
402    #[test]
403    fn a_functional_dependency_shows_up_as_a_dependence_of_one() {
404        // The `URL` and `URLHash` case from section 6.6. The hash is determined by the URL, so
405        // pairing them adds no distinct values.
406        let urls = values(200_000, "http://example.com/page/");
407        let mut left = Sketch::new(DEFAULT_K).unwrap();
408        let mut pairs = Sketch::new(DEFAULT_K).unwrap();
409        for url in &urls {
410            let derived = hash64(url).to_le_bytes();
411            left.add(url);
412            pairs.add_hash(pair_hash(url, &derived));
413        }
414        let score = dependence(&left, &pairs).unwrap();
415        assert!(score > 0.97, "{score:.4}");
416    }
417
418    #[test]
419    fn two_independent_columns_do_not_look_like_a_dependency() {
420        let left = values(1000, "left-");
421        let right = values(1000, "right-");
422        let mut alone = Sketch::new(DEFAULT_K).unwrap();
423        let mut pairs = Sketch::new(DEFAULT_K).unwrap();
424        for left_value in &left {
425            alone.add(left_value);
426            for right_value in &right {
427                pairs.add_hash(pair_hash(left_value, right_value));
428            }
429        }
430        let score = dependence(&alone, &pairs).unwrap();
431        assert!(score < 0.01, "{score:.4}");
432    }
433
434    #[test]
435    fn the_pair_hash_does_not_ignore_where_the_boundary_is() {
436        assert_ne!(pair_hash(b"ab", b"c"), pair_hash(b"a", b"bc"));
437        assert_ne!(pair_hash(b"a", b"b"), pair_hash(b"b", b"a"));
438    }
439
440    #[test]
441    fn the_hash_spreads_one_bit_changes_across_the_output() {
442        // Every estimate here assumes the hashes are uniform, so this measures the property rather
443        // than asserting it. Flipping one bit of the input has to change about half the output
444        // bits, and a hash that failed this would make every count above it wrong in a way that
445        // looks like the sketch is broken.
446        let mut total = 0u32;
447        let mut trials = 0u32;
448        for index in 0..2000u32 {
449            let value = index.to_le_bytes();
450            let base = hash64(&value);
451            for bit in 0..32 {
452                let mut flipped = value;
453                flipped[bit / 8] ^= 1 << (bit % 8);
454                total += (base ^ hash64(&flipped)).count_ones();
455                trials += 1;
456            }
457        }
458        let average = f64::from(total) / f64::from(trials);
459        assert!((average - 32.0).abs() < 1.0, "{average:.3} bits changed on average");
460    }
461
462    #[test]
463    fn the_hash_does_not_collide_on_values_that_differ_by_one_byte() {
464        // The shape of a real column: a million near identical URLs. A hash that collided here
465        // would make the distinct count an undercount and the overlap an overcount at the same
466        // time.
467        let mut hashes: Vec<u64> = (0..200_000u32)
468            .map(|index| hash64(format!("http://a/{index:09}").as_bytes()))
469            .collect();
470        hashes.sort_unstable();
471        let before = hashes.len();
472        hashes.dedup();
473        assert_eq!(hashes.len(), before);
474    }
475
476    #[test]
477    fn a_long_value_and_its_prefix_hash_differently() {
478        assert_ne!(hash64(b""), hash64(b"\0"));
479        assert_ne!(hash64(b"abcdefgh"), hash64(b"abcdefgh\0"));
480        assert_ne!(hash64(&[0u8; 16]), hash64(&[0u8; 24]));
481    }
482}