Skip to content

Commit 9167bb2

Browse files
committed
add a fast constructor if all items are available in one go
1 parent 4ffa548 commit 9167bb2

4 files changed

Lines changed: 209 additions & 6 deletions

File tree

crates/geo_filters/evaluation/performance.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
use std::hash::BuildHasher;
12
use std::hint::black_box;
23

34
use criterion::{criterion_group, criterion_main, Criterion};
45
use geo_filters::build_hasher::UnstableDefaultBuildHasher;
56
use geo_filters::config::VariableConfig;
6-
use geo_filters::diff_count::{GeoDiffCount, GeoDiffCount13};
7+
use geo_filters::diff_count::{GeoDiffCount, GeoDiffCount13, GeoDiffCount7};
78
use geo_filters::distinct_count::GeoDistinctCount13;
89
use geo_filters::evaluation::hll::Hll14;
910
use geo_filters::Count;
@@ -130,6 +131,50 @@ fn criterion_benchmark(c: &mut Criterion) {
130131
})
131132
});
132133
}
134+
135+
// Compare building a diff filter from a precomputed slice of hashes one by one
136+
// (`push_hash`) versus in a single batch (`from_hashes`). The hashes are precomputed so that
137+
// only the construction cost is measured.
138+
for size in [1000usize, 10000, 100000, 1000000] {
139+
let mut group = c.benchmark_group(format!("construct:{size}"));
140+
let build_hasher = UnstableDefaultBuildHasher::default();
141+
let hashes: Vec<u64> = (0..size).map(|i| build_hasher.hash_one(i)).collect();
142+
143+
group.bench_function("geo_diff_count_7_push", |b| {
144+
b.iter(|| {
145+
let mut gc = GeoDiffCount7::default();
146+
for &hash in &hashes {
147+
gc.push_hash(hash);
148+
}
149+
black_box(&gc);
150+
})
151+
});
152+
group.bench_function("geo_diff_count_7_from_hashes", |b| {
153+
b.iter(|| {
154+
black_box(GeoDiffCount7::from_hashes(
155+
Default::default(),
156+
hashes.iter().copied(),
157+
));
158+
})
159+
});
160+
group.bench_function("geo_diff_count_13_push", |b| {
161+
b.iter(|| {
162+
let mut gc = GeoDiffCount13::default();
163+
for &hash in &hashes {
164+
gc.push_hash(hash);
165+
}
166+
black_box(&gc);
167+
})
168+
});
169+
group.bench_function("geo_diff_count_13_from_hashes", |b| {
170+
b.iter(|| {
171+
black_box(GeoDiffCount13::from_hashes(
172+
Default::default(),
173+
hashes.iter().copied(),
174+
));
175+
})
176+
});
177+
}
133178
}
134179

135180
criterion_group!(benches, criterion_benchmark);

crates/geo_filters/src/config/lookup.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,24 @@ use crate::config::phi_f64;
22

33
pub(crate) struct HashToBucketLookup {
44
b: usize,
5-
buckets: Vec<(usize, usize)>,
5+
buckets: Vec<(u32, u32)>,
66
}
77

88
impl HashToBucketLookup {
99
pub(crate) fn new(b: usize) -> Self {
10-
let mut buckets = vec![(0, 0); 2 << b];
10+
let mut buckets = vec![(0u32, 0u32); 2 << b];
1111
let mut last_filled_bucket = buckets.len();
1212
let phi = phi_f64(b);
1313
for bucket in 0..(1 << b) {
1414
let lower_bucket_limit = phi.powf((bucket + 1) as f64);
15+
// `lower_hash_limit` is a 32-bit hash threshold: `lower_bucket_limit` lies in
16+
// `[0.5, 1)`, so this value is always in `[0, 2^32)` and fits losslessly into a `u32`.
1517
let lower_hash_limit = ((lower_bucket_limit - 0.5) * 2.0f64.powf(33.0)) as usize;
1618
let lower_hash_bucket = lower_hash_limit >> (32 - b - 1);
1719
assert!(lower_hash_bucket < last_filled_bucket);
1820
while last_filled_bucket > lower_hash_bucket {
1921
last_filled_bucket -= 1;
20-
buckets[last_filled_bucket] = (bucket, lower_hash_limit);
22+
buckets[last_filled_bucket] = (bucket as u32, lower_hash_limit as u32);
2123
}
2224
}
2325
assert_eq!(last_filled_bucket, 0);
@@ -38,8 +40,12 @@ impl HashToBucketLookup {
3840
} & 0xFFFFFFFF) as usize;
3941
// From those, the first B bits determine the bucket index in our lookup table.
4042
let idx = hash >> (32 - self.b - 1);
41-
let offset = (hash < self.buckets[idx].1) as usize;
42-
offset + self.buckets[idx].0 + (1 << self.b) * levels
43+
// SAFETY: `hash` was masked to 32 bits, so `idx = hash >> (31 - b)` holds at most `b + 1`
44+
// significant bits and is therefore always `< 2^(b+1) == 2 << b == self.buckets.len()`.
45+
debug_assert!(idx < self.buckets.len());
46+
let (base, threshold) = *unsafe { self.buckets.get_unchecked(idx) };
47+
let offset = (hash < threshold as usize) as usize;
48+
offset + base as usize + (1 << self.b) * levels
4349
}
4450
}
4551

crates/geo_filters/src/diff_count.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,55 @@ impl<'a, C: GeoConfig<Diff>> GeoDiffCount<'a, C> {
120120
result
121121
}
122122

123+
/// Constructs a [`GeoDiffCount`] directly from an iterator of item hashes.
124+
///
125+
/// Inserting hashes one by one via [`Count::push_hash`] repeatedly toggles individual bits
126+
/// and rebalances the sparse/dense split, which is wasteful when the hashes are known up
127+
/// front. Instead, this function uses the (exact) number of hashes to estimate the split
128+
/// point between the sparsely stored most-significant buckets (the "numbers") and the densely
129+
/// stored least-significant buckets (the "bits"):
130+
///
131+
/// * Buckets at or above the estimated split point are collected, sorted, and reduced
132+
/// modulo two so that buckets occurring an even number of times cancel out. This matches
133+
/// the symmetric-difference semantics of [`Diff`] and only sorts the comparatively few
134+
/// most-significant buckets.
135+
/// * Buckets below the split point are folded directly into a dense bit vector, where
136+
/// repeated buckets cancel out as well.
137+
///
138+
/// Both pieces are then combined into a single descending stream of one bits and handed to
139+
/// [`Self::from_bit_chunks`], which re-establishes the filter invariants. The resulting filter
140+
/// is identical to the one produced by pushing the same hashes one by one.
141+
pub fn from_hashes(config: C, hashes: impl ExactSizeIterator<Item = u64>) -> Self {
142+
let split = estimate_split_bucket(&config, hashes.len());
143+
144+
// Buckets >= split are sparse and collected for sorting; buckets < split are dense and
145+
// folded directly into the bit vector, where repeated buckets cancel via xor. The toggler
146+
// resolves the bit vector's owned storage once, keeping the per-bit work out of the loop.
147+
let mut numbers = Vec::new();
148+
let mut bits = BitVec::default();
149+
bits.resize(split);
150+
{
151+
let mut toggler = bits.toggler();
152+
for hash in hashes {
153+
let bucket = config.hash_to_bucket(hash).into_usize();
154+
if bucket >= split {
155+
numbers.push(bucket);
156+
} else {
157+
toggler.toggle(bucket);
158+
}
159+
}
160+
}
161+
162+
// Sort the most-significant buckets and drop those occurring an even number of times.
163+
numbers.sort_unstable_by(|a, b| b.cmp(a));
164+
erase_even_occurrences(&mut numbers);
165+
166+
Self::from_bit_chunks(
167+
config,
168+
iter_bit_chunks(numbers.into_iter(), bits.bit_chunks()),
169+
)
170+
}
171+
123172
/// Compare two geometric filters after applying the specified mask.
124173
///
125174
/// To reduce the number of operations, the implementation first xors the bit chunks together,
@@ -429,6 +478,47 @@ pub(crate) fn xor<C: GeoConfig<Diff>>(
429478
)
430479
}
431480

481+
/// Estimates the bucket id that separates the sparse most-significant buckets ("numbers") from
482+
/// the dense least-significant buckets ("bits") for a filter built from `n` hashes.
483+
///
484+
/// The expected number of hashes falling into buckets `>= s` is `n * phi^s`. We pick `s` such
485+
/// that this is a small multiple of the most-significant bucket budget, so that after parity
486+
/// reduction the most-significant buckets are almost always supplied by the collected numbers.
487+
/// Choosing a slightly lower split only makes the collected set a bit larger; correctness does
488+
/// not depend on the estimate, since [`GeoDiffCount::from_bit_chunks`] re-splits the combined
489+
/// stream regardless.
490+
fn estimate_split_bucket<C: GeoConfig<Diff>>(config: &C, n: usize) -> usize {
491+
let target = 2 * config.max_msb_len();
492+
if n <= target {
493+
return 0;
494+
}
495+
let ratio = target as f64 / n as f64;
496+
let split = (ratio.ln() / config.phi_f64().ln()).floor() as usize;
497+
// No bucket can ever exceed this bound, so never allocate a larger bit vector.
498+
split.min(64 * config.bits_per_level())
499+
}
500+
501+
/// Given a slice sorted in descending order, keeps a single copy of every value occurring an odd
502+
/// number of times and drops every value occurring an even number of times. The retained values
503+
/// stay sorted in descending order.
504+
fn erase_even_occurrences(values: &mut Vec<usize>) {
505+
let mut write = 0;
506+
let mut read = 0;
507+
while read < values.len() {
508+
let value = values[read];
509+
let mut count = 0;
510+
while read < values.len() && values[read] == value {
511+
read += 1;
512+
count += 1;
513+
}
514+
if count % 2 == 1 {
515+
values[write] = value;
516+
write += 1;
517+
}
518+
}
519+
values.truncate(write);
520+
}
521+
432522
impl<C: GeoConfig<Diff>> Count<Diff> for GeoDiffCount<'_, C> {
433523
fn push_hash(&mut self, hash: u64) {
434524
self.xor_bit(self.config.hash_to_bucket(hash));
@@ -542,6 +632,40 @@ mod tests {
542632
assert_eq!(m.iter_ones().count(), 101);
543633
}
544634

635+
/// Building a filter from an iterator of hashes must produce exactly the same filter as
636+
/// pushing those hashes one by one.
637+
#[test]
638+
fn test_from_hashes() {
639+
fn assert_from_hashes_matches<C: GeoConfig<Diff> + Default>(hashes: &[u64], n: usize) {
640+
let mut expected: GeoDiffCount<'_, C> = GeoDiffCount::new(C::default());
641+
for &hash in hashes {
642+
expected.push_hash(hash);
643+
}
644+
let actual: GeoDiffCount<'_, C> =
645+
GeoDiffCount::from_hashes(C::default(), hashes.iter().copied());
646+
assert_eq!(expected, actual, "filter mismatch for n = {n}");
647+
assert_eq!(
648+
expected.iter_ones().collect_vec(),
649+
actual.iter_ones().collect_vec(),
650+
"ones mismatch for n = {n}",
651+
);
652+
}
653+
654+
prng_test_harness(4, |rnd| {
655+
for n in [0usize, 1, 5, 50, 500, 5000, 50000] {
656+
// Draw from a smaller pool so that buckets are hit multiple times, exercising the
657+
// even-occurrence cancellation on both the dense bits and the sparse numbers path.
658+
let pool: Vec<u64> = (0..n.div_ceil(2).max(1)).map(|_| rnd.next_u64()).collect();
659+
let hashes: Vec<u64> = (0..n)
660+
.map(|_| *pool.iter().choose(rnd).expect("pool is non-empty"))
661+
.collect();
662+
663+
assert_from_hashes_matches::<GeoDiffConfig7>(&hashes, n);
664+
assert_from_hashes_matches::<GeoDiffConfig13>(&hashes, n);
665+
}
666+
});
667+
}
668+
545669
#[test]
546670
fn test_estimate_fast() {
547671
prng_test_harness(1, |rnd| {

crates/geo_filters/src/diff_count/bitvec.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ impl BitVec<'_> {
9090
self.blocks.to_mut()[block_idx] ^= bit_idx.into_block();
9191
}
9292

93+
/// Returns a [`BitToggler`] that toggles many bits without re-resolving the `Cow` on every
94+
/// access. [`Self::toggle`] resolves `self.blocks.to_mut()` on every call, which keeps a
95+
/// branch in the caller's hot loop even when the storage is already owned; resolving it once
96+
/// up front avoids that overhead when toggling a large number of bits.
97+
pub fn toggler(&mut self) -> BitToggler<'_> {
98+
BitToggler {
99+
num_bits: self.num_bits,
100+
blocks: self.blocks.to_mut(),
101+
}
102+
}
103+
93104
/// Returns an iterator over all blocks in reverse order.
94105
/// The blocks are represented as `BitChunk`s.
95106
pub fn bit_chunks(&self) -> impl Iterator<Item = BitChunk> + '_ {
@@ -200,6 +211,23 @@ impl Index<usize> for BitVec<'_> {
200211
}
201212
}
202213

214+
/// Toggles bits in an already-owned [`BitVec`] without re-resolving the `Cow` on every call.
215+
/// Obtained via [`BitVec::toggler`].
216+
pub(crate) struct BitToggler<'a> {
217+
num_bits: usize,
218+
blocks: &'a mut [u64],
219+
}
220+
221+
impl BitToggler<'_> {
222+
/// Toggles the bit at the given zero-based position. The position must be `< num_bits`.
223+
#[inline]
224+
pub fn toggle(&mut self, index: usize) {
225+
debug_assert!(index < self.num_bits);
226+
let (block_idx, bit_idx) = index.into_index_and_bit();
227+
self.blocks[block_idx] ^= bit_idx.into_block();
228+
}
229+
}
230+
203231
#[cfg(test)]
204232
mod tests {
205233
use super::*;

0 commit comments

Comments
 (0)