Skip to content

Add Sbbf::check_hashes and Sbbf::union, make Sbbf::insert_hash public - #11641

Merged
etseidl merged 13 commits into
apache:mainfrom
adriangb:sbbf-batch-union-apis
Nov 14, 2026
Merged

etseidl merged 13 commits into
apache:mainfrom
adriangb:sbbf-batch-union-apis

Conversation

@adriangb

@adriangb adriangb commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

DataFusion builds a parquet Sbbf as 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:

Need Today
Insert precomputed hashes insert_hash is private
Check many precomputed hashes fast check_hash is private, and exits early on a branch the CPU cannot predict
Merge filters built independently (one per thread or partition) Not possible

Measured 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:

API What
insert_hash(&mut self, u64) (now public) Insert one precomputed 64-bit hash
check_hashes(&self, &[u64]) -> BooleanBuffer (feature arrow) Branch-free bulk check: tests all 8 words of the block with no early exit
union(&mut self, &Sbbf) -> Result<()> OR another filter into this one. other must have the same number of blocks or a multiple of it. A larger other is folded on the fly (same grouping as fold_to_target_fpp), so fold(a ∪ b) == fold(a) ∪ fold(b)

The hashes can come from any well-mixed 64-bit hash. check / insert keep using the spec hash. The docs say the two only agree when the caller uses the spec hash, and that union needs both filters built with the same hash function.

There is no insert_hashes: it was a plain loop over insert_hash, so a slice API gave no speedup. check_hashes stays because the hash_api benchmark 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_hashes equals check_hash for each hash.
  • The union of per-chunk filters is bit-for-bit equal to one filter of all hashes.
  • Folding each part and then unioning equals folding the whole filter.
  • A larger filter is folded on the fly. A smaller filter, or an empty target, returns an error.
  • Benchmark: 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

@mbutrovich

Copy link
Copy Markdown
Contributor

Can we get some extra review cycles on #11549 and land that first, since it already brings in Sbbf?

adriangb added a commit to adriangb/datafusion that referenced this pull request Oct 17, 2026
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]>
@adriangb
adriangb force-pushed the sbbf-batch-union-apis branch from 2f6464e to 77292ad Compare October 18, 2026 04:37
adriangb added a commit to adriangb/datafusion that referenced this pull request Oct 18, 2026
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]>
@adriangb adriangb changed the title Add Sbbf hash, sharded insert and Arrow array batch APIs Add Sbbf hash and Arrow array batch APIs Oct 18, 2026
@adriangb

Copy link
Copy Markdown
Contributor Author

Thanks @mbutrovich . I reviewed #11549 and plan to restack this work on it once it merges (experiment for that in #11685)

@mbutrovich

Copy link
Copy Markdown
Contributor

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.

adriangb added a commit to adriangb/datafusion that referenced this pull request Oct 20, 2026
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]>
adriangb added a commit to adriangb/datafusion that referenced this pull request Oct 30, 2026
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]>
@adriangb adriangb changed the title Add Sbbf hash and Arrow array batch APIs Add Sbbf::insert_hashes, Sbbf::check_hashes and Sbbf::union Oct 30, 2026
@adriangb
adriangb force-pushed the sbbf-batch-union-apis branch from adfd09f to f929d5c Compare November 2, 2026 19:06
@etseidl

etseidl commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

@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.

@adriangb
adriangb force-pushed the sbbf-batch-union-apis branch from f929d5c to d2a52f9 Compare November 10, 2026 18:26
@adriangb

Copy link
Copy Markdown
Contributor Author

I've rebased. I can't mark this as ready / non-draft because I've hit my PR limit.

@adriangb
adriangb force-pushed the sbbf-batch-union-apis branch from d2a52f9 to f33fde9 Compare November 12, 2026 07:24
@adriangb
adriangb marked this pull request as ready for review November 12, 2026 07:32
@adriangb

Copy link
Copy Markdown
Contributor Author

@etseidl I've marked this as ready

@etseidl etseidl left a comment

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.

Thanks @adriangb, looks good. Just a few nits.

Comment thread parquet/src/bloom_filter/mod.rs Outdated
/// 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 {

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.

This will panic if len == 0. should guard against that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in e828a78

/// `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.

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.

Should we also state that the same hash needs to be used for other?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

👍🏻 done in 0b135b5

Comment thread parquet/src/bloom_filter/mod.rs Outdated
Comment on lines +979 to +981
folded.fold_to_target_fpp(0.01);
merged.fold_to_target_fpp(0.01);
assert_eq!(bitset(&folded), bitset(&merged));

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 314d407

Comment thread parquet/src/bloom_filter/mod.rs Outdated
/// 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

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.

Is it always much faster? Should this be qualified at all?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorry looks like 71e9f1e had gotten lost / overwritten. I now adopted your wording from #11641 (comment) (bfa6657)

adriangb and others added 3 commits November 13, 2026 14:01
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]>
@adriangb
adriangb force-pushed the sbbf-batch-union-apis branch from 14fe067 to 4e741a1 Compare November 13, 2026 20:05
@adriangb adriangb changed the title Add Sbbf::insert_hashes, Sbbf::check_hashes and Sbbf::union Add Sbbf::check_hashes and Sbbf::union, make Sbbf::insert_hash public Nov 13, 2026
@adriangb

Copy link
Copy Markdown
Contributor Author

@etseidl addressed your review. I also replaced insert_hashes with insert_hash which moves the loop to the caller but is a more flexible API (avoids materializing a slice, etc.). It was a loop internally.

@etseidl

etseidl commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Thanks @adriangb, this is next up in my queue.

@mbutrovich

Copy link
Copy Markdown
Contributor

FYI I'm taking a review pass now.

@mbutrovich mbutrovich left a comment

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.

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.

Comment thread parquet/src/bloom_filter/mod.rs Resolved
/// 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) {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread parquet/src/bloom_filter/mod.rs Outdated
Comment on lines +597 to +602
/// 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 {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Handled as per your suggestion in 0cc21e1. I agree a followup should make Sbbf::new return an error.

Comment thread parquet/src/bloom_filter/mod.rs Resolved
adriangb and others added 3 commits November 13, 2026 16:44
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]>
@adriangb
adriangb force-pushed the sbbf-batch-union-apis branch from 485a735 to 6cf668c Compare November 13, 2026 22:49
adriangb and others added 4 commits November 13, 2026 16:53
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]>
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]>
@adriangb
adriangb requested a review from mbutrovich November 13, 2026 23:13

@mbutrovich mbutrovich left a comment

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.

Thanks @adriangb!

adriangb and others added 2 commits November 13, 2026 17:33
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]>
@etseidl

etseidl commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Thanks for the review @mbutrovich. Hopefully merging will clear the audit CI failure. I think this is ready to go. 🚀

@etseidl

etseidl commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Thanks again @adriangb. Do you think we're ready for #11681 now? And do you have time to take a whack at it? I think you have more downstream context than I do.

@etseidl
etseidl added this pull request to the merge queue Nov 14, 2026
@adriangb

Copy link
Copy Markdown
Contributor Author

wohoo! thanks folks

Merged via the queue into apache:main with commit e4d721a Nov 14, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

parquet Changes to the parquet crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants