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    pair_of(hash64(left), hash64(right))
252}
253
254/// The same pair hash for two values whose hashes are already known.
255///
256/// Testing every pair of a 105 column table is 5,460 pairs, and hashing the two values again for
257/// each of them would hash every value of every column 104 times over. Hashing each column once a
258/// row and combining the results here is the same answer for a hundredth of the work, and it is the
259/// only way a pass over `hits` that tests all the pairs finishes in an afternoon.
260#[must_use]
261pub fn pair_of(left: u64, right: u64) -> u64 {
262    mix(left ^ SEEDS[3], right.wrapping_add(SEEDS[2]))
263}
264
265/// The constants are odd 64 bit values with about half their bits set, which is what a multiply
266/// based mixer needs to move low bits into high ones.
267const SEEDS: [u64; 4] =
268    [0xa076_1d64_78bd_642f, 0xe703_7ed1_a0b4_28db, 0x8ebc_6af0_9c88_c6e3, 0x5899_65cc_7537_4cc3];
269
270/// A 64 bit multiply of two values, folded to 64 bits by xoring the halves.
271///
272/// This is the whole strength of the hash. A 64 by 64 multiply moves every input bit into the high
273/// half of the product, and xoring the halves together brings them back down, so one of these turns
274/// a one bit change anywhere into a change in about half the output bits.
275fn mix(left: u64, right: u64) -> u64 {
276    let wide = u128::from(left).wrapping_mul(u128::from(right));
277    (wide as u64) ^ ((wide >> 64) as u64)
278}
279
280/// The hash used by every sketch here.
281///
282/// Nothing on disk depends on this, so it can be replaced with something faster without a format
283/// version. What it has to be is uniform, because every estimate in this module assumes the hashes
284/// are spread evenly over the range.
285#[must_use]
286pub fn hash64(value: &[u8]) -> u64 {
287    let mut state = SEEDS[0] ^ mix(value.len() as u64, SEEDS[1]);
288    let mut chunks = value.chunks_exact(8);
289    let mut word = [0u8; 8];
290    for chunk in &mut chunks {
291        word.copy_from_slice(chunk);
292        state = mix(state ^ u64::from_le_bytes(word), SEEDS[2]);
293    }
294    let rest = chunks.remainder();
295    if !rest.is_empty() {
296        let mut last = [0u8; 8];
297        last[..rest.len()].copy_from_slice(rest);
298        state = mix(state ^ u64::from_le_bytes(last), SEEDS[3]);
299    }
300    mix(state, SEEDS[1])
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn values(count: usize, prefix: &str) -> Vec<Vec<u8>> {
308        (0..count).map(|index| format!("{prefix}{index}").into_bytes()).collect()
309    }
310
311    fn borrow(values: &[Vec<u8>]) -> Vec<&[u8]> {
312        values.iter().map(Vec::as_slice).collect()
313    }
314
315    fn within(estimate: f64, actual: f64, tolerance: f64) -> bool {
316        (estimate - actual).abs() / actual <= tolerance
317    }
318
319    #[test]
320    fn a_sketch_that_never_filled_up_is_exact() {
321        let column = values(1000, "value-");
322        let sketch = Sketch::of(&borrow(&column));
323        assert!(sketch.is_exact());
324        assert_eq!(sketch.distinct(), 1000.0);
325    }
326
327    #[test]
328    fn duplicates_do_not_count() {
329        let mut sketch = Sketch::new(64).unwrap();
330        for _ in 0..1000 {
331            sketch.add(b"the same value");
332        }
333        assert_eq!(sketch.distinct(), 1.0);
334    }
335
336    #[test]
337    fn the_distinct_count_is_within_two_percent_at_the_default_k() {
338        for count in [50_000usize, 250_000, 1_000_000] {
339            let mut sketch = Sketch::new(DEFAULT_K).unwrap();
340            for index in 0..count {
341                sketch.add(format!("http://example.com/page/{index}").as_bytes());
342            }
343            assert!(!sketch.is_exact());
344            let estimate = sketch.distinct();
345            assert!(
346                within(estimate, count as f64, 0.02),
347                "{estimate:.0} against {count} distinct values"
348            );
349        }
350    }
351
352    #[test]
353    fn the_sketch_does_not_depend_on_the_order_values_arrived_in() {
354        let column = values(100_000, "value-");
355        let forwards = Sketch::of(&borrow(&column));
356        let mut backwards = Sketch::new(DEFAULT_K).unwrap();
357        for value in column.iter().rev() {
358            backwards.add(value);
359        }
360        assert_eq!(forwards, backwards);
361    }
362
363    #[test]
364    fn two_columns_with_the_same_values_overlap_completely() {
365        let column = values(200_000, "http://example.com/");
366        let left = Sketch::of(&borrow(&column));
367        let right = Sketch::of(&borrow(&column));
368        assert_eq!(left.jaccard(&right).unwrap(), 1.0);
369    }
370
371    #[test]
372    fn two_columns_with_nothing_in_common_do_not_overlap() {
373        let left = Sketch::of(&borrow(&values(200_000, "left-")));
374        let right = Sketch::of(&borrow(&values(200_000, "right-")));
375        assert_eq!(left.jaccard(&right).unwrap(), 0.0);
376    }
377
378    #[test]
379    fn a_half_overlap_measures_as_a_third() {
380        // Two columns of 100,000 values sharing 50,000 of them. The intersection is 50,000 and the
381        // union is 150,000, so the Jaccard similarity is a third and not a half, which is the
382        // number that catches people out about this measure.
383        let shared = values(50_000, "shared-");
384        let mut left = shared.clone();
385        left.extend(values(50_000, "left-"));
386        let mut right = shared;
387        right.extend(values(50_000, "right-"));
388        let overlap = Sketch::of(&borrow(&left)).jaccard(&Sketch::of(&borrow(&right))).unwrap();
389        assert!(within(overlap, 1.0 / 3.0, 0.05), "{overlap:.4}");
390    }
391
392    #[test]
393    fn the_union_of_two_sketches_counts_the_union_of_the_columns() {
394        let left = values(300_000, "left-");
395        let right = values(300_000, "right-");
396        let union = Sketch::of(&borrow(&left)).union(&Sketch::of(&borrow(&right))).unwrap();
397        assert!(within(union.distinct(), 600_000.0, 0.03), "{:.0}", union.distinct());
398    }
399
400    #[test]
401    fn sketches_of_different_sizes_do_not_combine() {
402        let small = Sketch::new(16).unwrap();
403        let large = Sketch::new(32).unwrap();
404        assert!(small.union(&large).is_err());
405        assert!(small.jaccard(&large).is_err());
406    }
407
408    #[test]
409    fn a_sketch_that_keeps_nothing_is_rejected() {
410        assert!(Sketch::new(0).is_err());
411    }
412
413    #[test]
414    fn a_functional_dependency_shows_up_as_a_dependence_of_one() {
415        // The `URL` and `URLHash` case from section 6.6. The hash is determined by the URL, so
416        // pairing them adds no distinct values.
417        let urls = values(200_000, "http://example.com/page/");
418        let mut left = Sketch::new(DEFAULT_K).unwrap();
419        let mut pairs = Sketch::new(DEFAULT_K).unwrap();
420        for url in &urls {
421            let derived = hash64(url).to_le_bytes();
422            left.add(url);
423            pairs.add_hash(pair_hash(url, &derived));
424        }
425        let score = dependence(&left, &pairs).unwrap();
426        assert!(score > 0.97, "{score:.4}");
427    }
428
429    #[test]
430    fn two_independent_columns_do_not_look_like_a_dependency() {
431        let left = values(1000, "left-");
432        let right = values(1000, "right-");
433        let mut alone = Sketch::new(DEFAULT_K).unwrap();
434        let mut pairs = Sketch::new(DEFAULT_K).unwrap();
435        for left_value in &left {
436            alone.add(left_value);
437            for right_value in &right {
438                pairs.add_hash(pair_hash(left_value, right_value));
439            }
440        }
441        let score = dependence(&alone, &pairs).unwrap();
442        assert!(score < 0.01, "{score:.4}");
443    }
444
445    #[test]
446    fn the_pair_hash_does_not_ignore_where_the_boundary_is() {
447        assert_ne!(pair_hash(b"ab", b"c"), pair_hash(b"a", b"bc"));
448        assert_ne!(pair_hash(b"a", b"b"), pair_hash(b"b", b"a"));
449    }
450
451    #[test]
452    fn combining_two_hashes_is_the_same_as_hashing_the_pair() {
453        // The lab hashes each column once a row and combines, and that has to be the same answer as
454        // hashing the two values together, or a dependency measured the fast way is not the
455        // dependency the slow way would have found.
456        for left in ["", "a", "http://example.com/one"] {
457            for right in ["", "b", "http://example.com/two"] {
458                assert_eq!(
459                    pair_hash(left.as_bytes(), right.as_bytes()),
460                    pair_of(hash64(left.as_bytes()), hash64(right.as_bytes()))
461                );
462            }
463        }
464    }
465
466    #[test]
467    fn the_hash_spreads_one_bit_changes_across_the_output() {
468        // Every estimate here assumes the hashes are uniform, so this measures the property rather
469        // than asserting it. Flipping one bit of the input has to change about half the output
470        // bits, and a hash that failed this would make every count above it wrong in a way that
471        // looks like the sketch is broken.
472        let mut total = 0u32;
473        let mut trials = 0u32;
474        for index in 0..2000u32 {
475            let value = index.to_le_bytes();
476            let base = hash64(&value);
477            for bit in 0..32 {
478                let mut flipped = value;
479                flipped[bit / 8] ^= 1 << (bit % 8);
480                total += (base ^ hash64(&flipped)).count_ones();
481                trials += 1;
482            }
483        }
484        let average = f64::from(total) / f64::from(trials);
485        assert!((average - 32.0).abs() < 1.0, "{average:.3} bits changed on average");
486    }
487
488    #[test]
489    fn the_hash_does_not_collide_on_values_that_differ_by_one_byte() {
490        // The shape of a real column: a million near identical URLs. A hash that collided here
491        // would make the distinct count an undercount and the overlap an overcount at the same
492        // time.
493        let mut hashes: Vec<u64> = (0..200_000u32)
494            .map(|index| hash64(format!("http://a/{index:09}").as_bytes()))
495            .collect();
496        hashes.sort_unstable();
497        let before = hashes.len();
498        hashes.dedup();
499        assert_eq!(hashes.len(), before);
500    }
501
502    #[test]
503    fn a_long_value_and_its_prefix_hash_differently() {
504        assert_ne!(hash64(b""), hash64(b"\0"));
505        assert_ne!(hash64(b"abcdefgh"), hash64(b"abcdefgh\0"));
506        assert_ne!(hash64(&[0u8; 16]), hash64(&[0u8; 24]));
507    }
508}