Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A collection of useful algorithms written in Rust. Currently contains:
- [`sparse-ngrams`](crates/sparse-ngrams): fast sparse n-gram extraction from byte slices. Selects variable-length n-grams (2–8 bytes) deterministically using bigram frequency priorities, suitable for substring search indexes.
- [`string-offsets`](crates/string-offsets): converts string positions between bytes, chars, UTF-16 code units, and line numbers. Useful when sending string indices across language boundaries.
- [`casefold`](crates/casefold): a **fast** Unicode simple case-folding library backed by a **very compact** (~1.7 KB) paged-bitmap + run-length table. Folds whole strings at multiple GiB/s via a decode-free `simple_fold` that rewrites UTF-8 with little-endian byte arithmetic, beating a `HashMap` fold table by several × at ~10× less memory.
- [`min-bloom-filter`](crates/min-bloom-filter): a split-block Bloom filter with four-bit values, max insertion, and min retrieval.

## Background

Expand Down
21 changes: 21 additions & 0 deletions crates/min-bloom-filter/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "min-bloom-filter"
version = "0.1.0"
edition = "2024"
description = "A split-block Bloom filter whose four-bit counters support max insertion and min retrieval."
repository = "https://github.com/github/rust-gems"
license = "MIT"
keywords = ["bloom-filter", "probabilistic", "filter", "data-structure"]
categories = ["algorithms", "data-structures"]

[lib]
bench = false

[[bench]]
name = "performance"
path = "benchmarks/performance.rs"
harness = false

[dev-dependencies]
criterion = "0.8"
sbbf-rs-safe = "0.3"
42 changes: 42 additions & 0 deletions crates/min-bloom-filter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# min-bloom-filter

A split-block Bloom filter that stores four-bit values instead of bits.

Each 32-byte block contains eight `u32` words, and each word contains eight
nibbles. A hash selects one nibble per word. Inserting raises all eight selected
nibbles to the maximum of their current value and the inserted value; retrieval
returns their minimum.

```rust
use min_bloom_filter::MinBloomFilter;

let mut filter = MinBloomFilter::new(3_000_000, 0.01);
filter.insert(0x1234_5678_9abc_def0, 7);

assert_eq!(filter.get(0x1234_5678_9abc_def0), 7);
```

Values must be in `0..=15`. Like a regular Bloom filter, collisions can produce
false positives: a key that was not inserted can retrieve a nonzero value.

## Performance

Criterion results on an Apple M4 Max for 3 million entries:

| Target FPR | Memory | Insert | Retrieve |
|---|---:|---:|---:|
| 1% | 14.52 MB | 2.27 ns/entry | 1.42 ns/entry |
| 0.1% | 21.91 MB | 2.74 ns/entry | 1.58 ns/entry |

For comparison, `sbbf-rs-safe` on the same hashes:

| Target FPR | Insert | Contains |
|---|---:|---:|
| 1% | 1.50 ns/entry | 1.24 ns/entry |
| 0.1% | 1.55 ns/entry | 1.26 ns/entry |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to explain that this is doing something else so the better performance isn't apples-to-apples.


Run these benchmarks with:

```console
cargo bench -p min-bloom-filter --bench performance
```
136 changes: 136 additions & 0 deletions crates/min-bloom-filter/benchmarks/performance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use std::hint::black_box;

use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use min_bloom_filter::MinBloomFilter;
use sbbf_rs_safe::Filter as Sbbf;

const ENTRY_COUNT: usize = 3_000_000;
const RATES: [(&str, f64); 2] = [("1_percent", 0.01), ("0.1_percent", 0.001)];

fn hashes() -> Vec<u64> {
(0..ENTRY_COUNT)
.map(|index| splitmix64(index as u64))
.collect()
}

fn benchmark_insert(c: &mut Criterion) {
let hashes = hashes();
let mut group = c.benchmark_group("insert_3m");
group.sample_size(10);
group.throughput(Throughput::Elements(ENTRY_COUNT as u64));

for (name, rate) in RATES {
group.bench_with_input(BenchmarkId::new("fpr", name), &rate, |b, rate| {
b.iter_batched(
|| MinBloomFilter::new(ENTRY_COUNT, *rate),
|mut filter| {
for (index, hash) in hashes.iter().copied().enumerate() {
filter.insert(black_box(hash), ((index % 15) + 1) as u8);
}
black_box(filter);
},
criterion::BatchSize::LargeInput,
);
});
}
group.finish();
}

fn benchmark_get(c: &mut Criterion) {
let hashes = hashes();
let mut group = c.benchmark_group("get_3m");
group.sample_size(10);
group.throughput(Throughput::Elements(ENTRY_COUNT as u64));

for (name, rate) in RATES {
let mut filter = MinBloomFilter::new(ENTRY_COUNT, rate);
for (index, hash) in hashes.iter().copied().enumerate() {
filter.insert(hash, ((index % 15) + 1) as u8);
}

group.bench_with_input(BenchmarkId::new("fpr", name), &filter, |b, filter| {
b.iter(|| {
let mut sum = 0_u64;
for hash in hashes.iter().copied() {
sum += u64::from(filter.get(black_box(hash)));
}
black_box(sum)
});
});
}
group.finish();
}

fn benchmark_sbbf_insert(c: &mut Criterion) {
let hashes = hashes();
let mut group = c.benchmark_group("sbbf_insert_3m");
group.sample_size(10);
group.throughput(Throughput::Elements(ENTRY_COUNT as u64));

for (name, rate) in RATES {
let bits_per_entry = bloom_bits_per_entry(rate);
group.bench_with_input(
BenchmarkId::new("fpr", name),
&bits_per_entry,
|b, bits_per_entry| {
b.iter_batched(
|| Sbbf::new(*bits_per_entry, ENTRY_COUNT),
|mut filter| {
for hash in hashes.iter().copied() {
black_box(filter.insert_hash(black_box(hash)));
}
black_box(filter);
},
criterion::BatchSize::LargeInput,
);
},
);
}
group.finish();
}

fn benchmark_sbbf_contains(c: &mut Criterion) {
let hashes = hashes();
let mut group = c.benchmark_group("sbbf_contains_3m");
group.sample_size(10);
group.throughput(Throughput::Elements(ENTRY_COUNT as u64));

for (name, rate) in RATES {
let mut filter = Sbbf::new(bloom_bits_per_entry(rate), ENTRY_COUNT);
for hash in hashes.iter().copied() {
filter.insert_hash(hash);
}

group.bench_with_input(BenchmarkId::new("fpr", name), &filter, |b, filter| {
b.iter(|| {
let mut matches = 0_u64;
for hash in hashes.iter().copied() {
matches += u64::from(filter.contains_hash(black_box(hash)));
}
black_box(matches)
});
});
}
group.finish();
}

fn bloom_bits_per_entry(false_positive_rate: f64) -> usize {
let probes = 8.0;
(-probes / (1.0 - false_positive_rate.powf(1.0 / probes)).ln()).ceil() as usize
}

fn splitmix64(mut value: u64) -> u64 {
value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}

criterion_group!(
benches,
benchmark_insert,
benchmark_get,
benchmark_sbbf_insert,
benchmark_sbbf_contains
);
criterion_main!(benches);
Loading