Repository navigation
Add Sbbf::check_hashes and Sbbf::union, make Sbbf::insert_hash public - #11641
Conversation
|
Can we get some extra review cycles on #11549 and land that first, since it already brings in |
Pointing at arrow-rs main pulled in unrelated behavior changes (interleave buffer sharing, DST handling). Use a branch with the 60.0.0 tag, apache#11088 and the commits of apache/arrow-rs#11641 instead. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2f6464e to
77292ad
Compare
Points the arrow-rs crates at adriangb/arrow-rs sbbf-batch-union-apis-60: arrow-rs 60.0.0 + apache/arrow-rs#11088 + the commit of apache/arrow-rs#11641. Remove once arrow-rs releases these APIs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thanks @mbutrovich . I reviewed #11549 and plan to restack this work on it once it merges (experiment for that in #11685) |
Sounds good, thanks for the review pass! I was excited to see someone with another use for it so wanted to make sure it lands with what you need. |
Points the arrow-rs crates at adriangb/arrow-rs sbbf-batch-union-apis-60: arrow-rs 60.0.0 + apache/arrow-rs#11088 + the commit of apache/arrow-rs#11641. Remove once arrow-rs releases these APIs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Each build partition now fills its own Sbbf from the join hashes (HASH_JOIN_SEED, the same hashes the probe side computes) in its build task, sized for its distinct key count times the number of partitions. At the barrier the partition filters are ORed with Sbbf::union, folded to the target fpp, and dropped if they still exceed it. The probe checks a whole batch with the branch-free Sbbf::check_hashes. This removes the sort/dedup step, the serial fill at the barrier and the per-column filters, and gives float keys a bloom filter too. hash_join_bloom_filter_max_bytes now defaults to 8 MiB: larger filters fall out of cache and were slower in TPC-H SF100 benchmarks. Bumps the arrow-rs pin for Sbbf::insert_hashes / union / check_hashes (apache/arrow-rs#11641). Co-Authored-By: Claude Opus 5.5 <[email protected]>
adfd09f to
f929d5c
Compare
|
@adriangb do you want to dust this one off? I think we're close to the point of pulling bloom filters into a new crate. |
f929d5c to
d2a52f9
Compare
|
I've rebased. I can't mark this as ready / non-draft because I've hit my PR limit. |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
d2a52f9 to
f33fde9
Compare
|
@etseidl I've marked this as ready |
| /// thread) be built and then merged. | ||
| pub fn union(&mut self, other: &Sbbf) -> Result<(), ParquetError> { | ||
| let (len, other_len) = (self.0.len(), other.0.len()); | ||
| if other_len < len || other_len % len != 0 { |
There was a problem hiding this comment.
This will panic if len == 0. should guard against that.
| /// `other`. Folding and union commute: the result is the same as | ||
| /// inserting the values of both filters into one filter and folding it | ||
| /// to this size. This lets independent filters (for example, one per | ||
| /// thread) be built and then merged. |
There was a problem hiding this comment.
Should we also state that the same hash needs to be used for other?
| folded.fold_to_target_fpp(0.01); | ||
| merged.fold_to_target_fpp(0.01); | ||
| assert_eq!(bitset(&folded), bitset(&merged)); |
There was a problem hiding this comment.
This is folding two filters we've already established above are identical. I think the loop above needs to be repeated, with each part folded before unioning into merged.
| /// Checks precomputed 64-bit hashes (see [`Self::insert_hashes`]). | ||
| /// | ||
| /// Bit `i` of the result is set if `hashes[i]` may be in the filter. The | ||
| /// check has no data-dependent branches, which is much faster than |
There was a problem hiding this comment.
Is it always much faster? Should this be qualified at all?
There was a problem hiding this comment.
You're right. It's not always faster. I ran some benchmarks and tried to distill the results into a comment (71e9f1e).
You're right, it's not always faster, so I dropped the performance claim: the doc now just says it is a branch-free bulk check.
TLDR of my benchmarks (2 MiB filter, precomputed hashes, the early-exit check called once per hash via BooleanBuffer::collect_bool, as a caller would write it):
With hits and misses mixed, branch-free is faster. At 50% hits it is 1.7–2.2x faster, depending on filter size.
When every hash misses, it depends on how full the filter is:
| bits set | early exit vs branch-free |
|---|---|
| 1% | 0.85x (early exit wins) |
| 5% | 0.92x |
| 13% | 1.04x |
| 28% | 1.43x |
| 51% (sized for 1% fpp) | 2.38x |
A miss in a nearly empty filter fails on the first word, so the early exit predicts well. In a filter sized for its target fpp, a miss fails at an unpredictable word and the branch mispredicts. Both versions pay the same one-cache-line load, so testing all 8 words costs little extra.
So the early exit only wins on a nearly empty filter, and even then by less than 15%. The new hash_api benchmark in parquet/benches/bloom_filter.rs covers sparse and 1%-fpp filters with miss, hit and mixed queries.
Nonetheless, I softened the wording on the comment and kept it generally correct without going into this level of detail.
There was a problem hiding this comment.
Nonetheless, I softened the wording on the comment and kept it generally correct without going into this level of detail.
The comment on Block::check_branch_free still says "so it is faster when many hashes are checked in a loop", the same text as in the first commit. Your table shows the early exit winning on all-miss queries against filters with 1% and 5% of bits set. Could we say when each one wins? For example:
/// Same result as [`Block::check`], but tests all 8 words without an early
/// exit. This avoids a mispredicted branch when hits and misses are mixed
/// or the filter is well filled. The early exit is faster when nearly
/// every hash misses a sparse filter.
There was a problem hiding this comment.
Sorry looks like 71e9f1e had gotten lost / overwritten. I now adopted your wording from #11641 (comment) (bfa6657)
Sbbf::union computed `other_len % len`, which panics when this filter has no blocks. Use checked_rem so an empty filter returns an error, and document the error cases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The union is meaningless if the two filters used different hash functions, and Sbbf cannot detect that, so the doc states it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The branch-free check wins when hits and misses are mixed, but the early exit of a per-hash check wins when nearly every hash misses. The doc now says both. Co-Authored-By: Claude Opus 5.5 <[email protected]>
14fe067 to
4e741a1
Compare
|
@etseidl addressed your review. I also replaced |
|
Thanks @adriangb, this is next up in my queue. |
|
FYI I'm taking a review pass now. |
mbutrovich
left a comment
There was a problem hiding this comment.
Thanks @adriangb, it's good to see a second user of Sbbf folding.
I've got some anchored comments, but also don't want this reply lost.
| /// only finds values whose hashes were computed as the Parquet | ||
| /// specification requires (xxHash64, seed 0, of the plain encoding); use | ||
| /// [`Self::check_hashes`] with the same hash function instead. | ||
| pub fn insert_hash(&mut self, hash: u64) { |
There was a problem hiding this comment.
insert_hash is public in every build, but check_hashes is the only way to query those hashes, and it needs the arrow feature. Without arrow, a caller can fill a filter with their own hashes and never check it. With arrow, a caller that has one probe hash has to build a BooleanBuffer.
Should check_hash be public too? The early exit is a reason to offer check_hashes for bulk probes, but it doesn't stop check_hash from being the single-hash counterpart to insert_hash. If it becomes public, the insert_hash docs could point to it alongside check_hashes.
There was a problem hiding this comment.
I'd propose we wait until someone requests it (the use I have in Datafusion doesn't need it) but happy to do it now for API parity if you'd like.
| /// Checks precomputed 64-bit hashes (see [`Self::insert_hash`]) in bulk, | ||
| /// without data-dependent branches. | ||
| /// | ||
| /// Bit `i` of the result is set if `hashes[i]` may be in the filter. | ||
| #[cfg(feature = "arrow")] | ||
| pub fn check_hashes(&self, hashes: &[u64]) -> arrow_buffer::BooleanBuffer { |
There was a problem hiding this comment.
check_hashes panics on a filter with no blocks, because hash_to_block_index returns 0 and self.0[0] is out of bounds. Sbbf::new returns such a filter for any bitset shorter than 32 bytes, so Sbbf::new(&[0; 31]).check_hashes(&[1]) panics. insert_hash panics the same way.
This predates the PR: insert and check on main go through the same hash_to_block_index and index. But union in this PR returns an error for an empty filter, so the three new methods disagree on that input. Do you want to handle it here, or in a separate PR? Here, an empty filter contains nothing, so check_hashes could return all unset bits, and insert_hash could document the panic in a # Panics section. A separate PR could stop Sbbf::new from producing an empty filter.
There was a problem hiding this comment.
Handled as per your suggestion in 0cc21e1. I agree a followup should make Sbbf::new return an error.
insert_hashes was a plain loop over insert_hash, so a slice API gives no speedup. check_hashes stays: its branch-free check beats a per-hash early-exit loop unless the filter is nearly empty. Adds the hash_api benchmark (sparse and 1% FPP filters, miss/hit/mixed queries). Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fold-then-union check folded `whole` and `merged`, which the test had already shown equal, so it could not fail. Fold each part before the union instead, as review asked. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: Matt Butrovich <[email protected]>
485a735 to
6cf668c
Compare
The Block::check_branch_free doc claimed it is always faster in a loop. The hash_api benchmark shows the early exit winning when nearly every hash misses a sparse filter, so the doc now says when each one wins. Co-authored-by: Matt Butrovich <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: Matt Butrovich <[email protected]>
Sbbf::new returns a filter with no blocks for a bitset shorter than 32 bytes. check_hashes then indexed block 0 and panicked, while union returns an error for the same filter. An empty filter contains nothing, so check_hashes now returns all unset bits. insert_hash cannot do anything sensible with an empty filter, so its doc states the panic. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The example calls check_hashes, which needs the arrow feature, so the doctest failed to compile with --no-default-features. The check lines now only run when arrow is enabled. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The bench calls Sbbf::check_hashes, which exists only with the arrow feature, so the bench failed to build without it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thanks for the review @mbutrovich. Hopefully merging will clear the audit CI failure. I think this is ready to go. 🚀 |
|
wohoo! thanks folks |
Which issue does this PR close?
Sbbf::estimated_fppto expose the bloom filter FPP estimate #11088 (Sbbf::estimated_fpp, merged), Add bloom filter folding to automatically size SBBF filters #9628 (folding, merged)Rationale for this change
DataFusion builds a parquet
Sbbfas the membership test in the dynamic filter of a hash join. Each build partition fills its own filter in parallel with hashes it has already computed. The filters are merged into one, the result is folded to the target fpp, and every probe row is checked against it.The public API cannot do this today:
insert_hashis privatecheck_hashis private, and exits early on a branch the CPU cannot predictMeasured in DataFusion on 16M probe rows: a per-row branchy check takes ~104 ms, the branch-free check ~21 ms.
What changes are included in this PR?
All additions are on
parquet::bloom_filter::Sbbf:insert_hash(&mut self, u64)(now public)check_hashes(&self, &[u64]) -> BooleanBuffer(featurearrow)union(&mut self, &Sbbf) -> Result<()>othermust have the same number of blocks or a multiple of it. A largerotheris folded on the fly (same grouping asfold_to_target_fpp), sofold(a ∪ b) == fold(a) ∪ fold(b)The hashes can come from any well-mixed 64-bit hash.
check/insertkeep using the spec hash. The docs say the two only agree when the caller uses the spec hash, and thatunionneeds both filters built with the same hash function.There is no
insert_hashes: it was a plain loop overinsert_hash, so a slice API gave no speedup.check_hashesstays because thehash_apibenchmark shows the branch-free check beats a per-hash early-exit loop on mixed hit/miss queries. It is slightly slower when nearly every hash misses.Are these changes tested?
check_hashesequalscheck_hashfor each hash.cargo bench -p parquet --bench bloom_filter -- hash_api.Are there any user-facing changes?
New public API only. No breaking changes.
🤖 Generated with Claude Code