This document explains the raw-NIF hashing path added to the six membership-filter families -- Bloom, Cuckoo, Quotient, CQF, XorFilter, IBLT -- in v0.10.0 Phase 6, and the measured throughput improvement it brings.
Why this existed as a gap
ExDataSketch.HLL, CMS, Theta, and ULL have hashed items inside
the Rust NIF since v0.8.0 (see guides/hll_performance.md): update_many/2
hands raw item binaries straight to Rust, which hashes them with XXH3 or
Murmur3 and updates the sketch in the same native call. The six
membership filters did not get this treatment -- put_many/2 (or
build/2 for XorFilter) still computed each item's hash64 in Elixir
via ExDataSketch.Hash.hash64/1 before crossing the NIF boundary, even
when using Backend.Rust. This was flagged as G6 in the v0.9.0 code
review and deferred twice (baoulo/plans/0.9.0_phase5_carry_forward.md:188)
because it needed a dedicated phase, not because it was hard -- the
per-family bit-setting, bucket-insertion, and peeling-construction logic
in Rust already existed and was already tested; only the hash computation
needed to move.
What changed
Each family's Backend.Rust callback gained a _raw sibling, exactly
mirroring the shape hll_update_many_raw/3 already established:
Bloom.put_many/2->Backend.Rust.bloom_put_many_raw/3Cuckoo.put_many/2->Backend.Rust.cuckoo_put_many_raw/3Quotient.put_many/2->Backend.Rust.quotient_put_many_raw/3CQF.put_many/2->Backend.Rust.cqf_put_many_raw/3IBLT.put_many/2->Backend.Rust.iblt_put_many_raw/3(set-mode only, matchingupdate_many/2's existing scope -- seeiblt.ex)XorFilter.build/2->Backend.Rust.xor_build_raw/2(the one structurally different family: construction is a batch peeling algorithm over the whole set, not an incremental insert, so there is no per-item raw "put", only a raw "build")
Each _raw NIF pair (_raw_nif/_raw_dirty_nif for XXH3,
_raw_h_nif/_raw_h_dirty_nif for a dispatched algorithm byte) hashes
the raw item bytes with xxhash_rust::xxh3 or the existing Murmur3
implementation in native/ex_data_sketch_nif/src/hash.rs, then feeds the
resulting hash64 into the same, unmodified per-item logic the
existing non-raw NIF already used (Kirsch-Mitzenmacher double-hashing for
Bloom, cuckoo kick-insertion, quotient/CQF slot insertion, IBLT cell-XOR,
xor-filter peeling). No algorithmic Rust code changed -- only where the
hash is computed.
Path dispatch logic
Identical in shape to the cardinality families' dispatch
(guides/hll_performance.md's "Path dispatch logic" section):
if backend == Backend.Pure:
hashes = items |> Enum.map(&hash_item/2)
Backend.Pure.<family>_put_many(state, hashes, opts)
elif backend == Backend.Rust:
if opts[:hash_fn] != nil or opts[:hash_strategy] == :phash2:
# Custom closure or phash2: must run on the BEAM.
hashes = items |> Enum.map(&hash_item/2)
Backend.Rust.<family>_put_many(state, hashes, opts)
else:
# :xxhash3 or :murmur3 -> Rust hashing, raw path.
Backend.Rust.<family>_put_many_raw(state, items, opts):hash_fn and :hash_strategy are honored exactly as before -- a custom
hash closure cannot run inside Rust, so it always falls back to the
pre-hash-in-Elixir path, matching Pure's behavior byte-for-byte
(test/parity_test.exs has a dedicated :hash_fn parity test per
family, added in this phase).
Byte-identical parity
test/parity_test.exs already had a parity block per filter family
(asserting Pure.serialize(...) == Rust.serialize(...) for put_many/
build, merge, and member?) under default options -- exactly the
options the new raw dispatch activates under. Once each family's
put_many/2/build/2 was rewired, these existing tests started
exercising the raw path automatically, the same way "HLL parity"'s
existing test already covered hll_update_many_raw. No new algorithmic
surface needed new correctness tests; only the :hash_fn fallback-path
tests were genuinely new (there was no pre-existing coverage of "does the
raw path get correctly skipped" for any raw family, cardinality or
filter).
Measured throughput
Measured on Apple M1 Max / OTP 29 / Elixir 1.20.2, 10,000 items per
operation, via bench/filter_raw_hashing_bench.exs:
| Family | Pure | Rust (pre-hashed, legacy) | Rust (raw) | Raw vs legacy | Raw vs Pure |
|---|---|---|---|---|---|
| Bloom | 9.6 K/s | 4.85 M/s | 16.7 M/s | 3.4x | 1,741x |
| Cuckoo | 348 K/s | 4.94 M/s | 18.2 M/s | 3.7x | 52x |
| Quotient | 8.7 K/s | 3.29 M/s | 7.1 M/s | 2.2x | 820x |
| CQF | 9.1 K/s | 3.27 M/s | 7.2 M/s | 2.2x | 793x |
| IBLT | 6.7 K/s | 1.96 M/s | 9.0 M/s | 4.6x | 1,338x |
| XorFilter (build) | 6.9 K/s | 1.33 M/s | 17.0 M/s | 12.8x | 2,469x |
("Rust (pre-hashed, legacy)" reproduces the pre-Phase-6 path directly
against Backend.Rust's pre-hashed functions, bypassing the new
automatic raw dispatch -- the six per-family bench/*_bench.exs files
also carry this scenario permanently, labeled [Rust (pre-hashed, legacy)], for regression tracking alongside their other operations.)
Headline numbers:
- Raw vs legacy Rust ranges from 2.2x (Quotient/CQF) to 12.8x
(XorFilter). Quotient and CQF's smaller gain makes sense: their
per-item insertion logic (run-finding, shifting) is more expensive
relative to hashing than Bloom/Cuckoo's simpler bit/bucket writes, so
removing the hashing step matters proportionally less. XorFilter's
outsized gain reflects
build/2's legacy path paying forEnum.uniq/1and a full binary round-trip of already-hashed values in Elixir before Rust even starts the peeling construction; raw dispatch skips that entirely. - Cuckoo's Pure number (348 K/s) is far higher than the other five filters' Pure numbers (~7-10 K/s) at this same 10,000-item, 20,000- capacity configuration. This is a genuine property of Cuckoo's simpler per-item Pure path (mostly direct binary bit-twiddling, low load factor so few kicks), not a benchmark artifact -- also reflected in Cuckoo having the smallest raw-vs-Pure ratio (52x) of the six families.
- All six families now sit in the same throughput class as HLL/CMS/
Theta/ULL's raw paths (
guides/hll_performance.mdmeasured 25-34 M items/s for HLL specifically; the filters' per-item work is heavier than a single register write, so 7-18 M items/s here is the expected relative position, not a regression).
Reproducing these numbers
MIX_ENV=dev mix run bench/filter_raw_hashing_bench.exs
For per-family detail across more operations (merge, member?,
serialize, and so on, at both 1k and 100k item scales), run the
individual family benchmarks, each of which now also includes the
[Rust (pre-hashed, legacy)] comparison scenario for its put_many/
build entries:
MIX_ENV=dev mix run bench/bloom_bench.exs
MIX_ENV=dev mix run bench/cuckoo_bench.exs
MIX_ENV=dev mix run bench/quotient_bench.exs
MIX_ENV=dev mix run bench/cqf_bench.exs
MIX_ENV=dev mix run bench/iblt_bench.exs
MIX_ENV=dev mix run bench/xor_filter_bench.exs
Outputs JSON to bench/output/*.json for further analysis.
References
lib/ex_data_sketch/bloom.ex,cuckoo.ex,quotient.ex,cqf.ex,iblt.ex,xor_filter.ex-- high-levelput_many/2/build/2raw dispatch (mirroringhll.ex'supdate_many/2).lib/ex_data_sketch/backend/rust.ex-- Rust-side dispatch (bloom_put_many_raw/3and siblings).native/ex_data_sketch_nif/src/bloom.rs,cuckoo.rs,quotient.rs,cqf.rs,iblt.rs,xor_filter.rs-- Rust implementations; each family's per-item logic is factored into a shared helper (bloom_set_bits,cko_insert_one,iblt_insert_one,xor_build_from_hashes, and so on) called identically by the pre-hashed and raw paths.test/parity_test.exs-- byte-identical Pure/Rust parity, including the:hash_fnfallback-path tests added in this phase.bench/filter_raw_hashing_bench.exs-- this document's measurement source.guides/hll_performance.md-- the sibling document for HLL/CMS/Theta/ ULL's raw-hashing architecture, which this phase's design directly extends.baoulo/plans/0.10.0_phase6_design_review.md-- the full phase design review.