Bloom filter implementation - #9398
Conversation
8ce1415 to
43b064d
Compare
|
The current implementation has no benchmarks, and I saw divan used in the encoders. It would be great to have some, at least for |
There was a problem hiding this comment.
A few things on this first pass that I saw, I haven't actually grokked the algorithm myself either so in the next round I'll review the actual filter algorithm.
I was also doing some digging into how this works, and I believe that your impl follows the parquet rust impl, but that's actually different from the paper slightly? Maybe it is fine. I will also eventually start looking at the autovectorization and LLVM IR to verify if the compiler is doing it's job (because sometimes it just doesn't)
Also, please fix the lints and other CI issues. Feel free to ping me so I can approve the workflows to run.
|
We probably also want to have a goldenfile for this? To make sure we don't break things accidentally in the future |
|
Thanks for the quick review Connor.
At first, I translated the implementation from the paper into AVX2 and NEON. Then I switched to a single portable implementation (Codex convinced me), and I wouldn’t be surprised if it led to an implementation similar to the Parquet Rust impl. Next, I compared both implementations on Godbolt (this link contains both implementations), and I was happy with the result. |
|
@joacoc that makes sense. Do you know if this vectorizes for avx512 as well? Regardless I think we want to have some sort of goldenfile that we can use for backcompat checks. Not sure what the best way to do that is... In the meantime could you resolve the merge conflicts and other comments I had? Then once it's cleaned up I can do a deeper review |
|
Just checked, and avx512 vectorizes ok. It uses the same instructions as AVX2. The implementation targets 256-bit vectors, so that makes sense. I agree about the golden-file test. The current smoke test is clearly not enough. I’ll come up with something. The filter can already serialize to bytes, so it should be ok. I’ll update the PR details so we can track everything there. |
|
Can you add a comment of the stability of the bloom filter w.r.t. serialisation. Do you see us using other hash function or serialisation of the buckets going forwards? Should we add somewhere in the ID or Options the format of the bloom filter? |
|
@joseph-isaacs Yes. I see the serialization of the buckets to stay stable. If we change the bucket layout itself, I think that should be a new filter type, rather than an evolution of this one. On the other hand, changing the hash function seems much more plausible. I think making the hash function an option with an id, and storing that id in the serialized options/metadata, would make the filter more flexible and future-proof. Does that make sense? |
|
Can we also include further docs explaining how this work in source with a link to more detail if needed.
What about the numbers of hash functions k and seed. Are these fix or should we include these? |
Yep. I’ll add that to the pending tasks.
If The hash seed is an interesting case, but after thinking about it, I would keep it fixed. I don't have a clear concrete case (just speculations) that would justify a custom seed for this implementation, or why a future hash function would make it a more important parameter that a user would need to change it. Even though the implementation and the seed storage size would be really small. The format I barely tried out is as follows: pub struct BloomOptions {
blocks_count: NonZeroU32,
hash_fn: HashFn
}
enum HashFn {
XxHash3_64, // ID = ...
XxHash64, // ID = ...
...
} |
|
So, added configurable hashing and would love to highlight two things:
BenchmarkSpec: mac m1Match/case
Without match/case (just a default)
|
|
This is great Lets keep this as a single hash function for now! Want to fixup CI and then I can do a final pass! |
Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
…ement constant/canonical accumulators.
…ion comments and fix primitive test for validation
…from bytes, and use constant for block_size
…e those cases. Also remove aggregates/min_max.rs and aggregates/mod.rs, and re-add them once the integration is done (they're currently in the writer) so the skip index integration PR should re-add them. This commit also adds a new composable type to the bloom filter (map) but yet not supported
I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: a9e0550 I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: 8d81b78 I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: 7d0495f I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: 43b064d I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: f6ee474 Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
…so, replace T: Hash with AsRef[u8], given that this is what the oneshot expects. Primitive types translate to the new tra it, but other DTypes require to convert into bytes. Having a macro/trait over DType to convert into the underlying bytes could be useful if more agg. fns need this convertion. Also made the partial struct more practical to avoid having to expose blocks into the super BloomFilter struct. Performance improvement: removed branch inside find_hash :) Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
…ation, and crates/mods calls to be more legible. Also added decimal validity tests. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
Because canonicals/constants focus on type-specific behavior rather than sizes. Also moved test helpers to the end of the file, and renamed `blocks` to `blocks_count` to be more precise in `BloomOptions`. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
This commit fixes the CI errors, but it also requires some code changes: 1. Use NonZeroU32 over NonZeroUsize, and control checks over 'blocks_count' and block length during serialization. 2. Add explicit cast and documentation for hashes within the Bloom filter implementation. The second point required a bit more documentation and reference to explain the reasons. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
Disable decimal support until values have a stable representation. See vortex-data#5820. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
…s different filter sizes Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
The idea for the test is to detect changes when the salt changes, similar to the hash function test. Also adds more documentation about the order, and why it is in that way. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
So, this commit is mostly around documentation and leaving a single function for hashing. By adding more documentation, I saw the opportunity to rename some variables to match what the documentation explains, for example, using "splits" rather than "lanes." Both are okay, but "splits" matches the documentation. On the other side, by having a single hash function, it was no longer necessary to keep the match for hashes, the implementations for each one, or the clarification about seed usage. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: fab1c58 I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: 081791f I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: 175cd28 I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: b435a96 I, Joaquin Colacci <joaquincolacci@gmail.com>, hereby add my Signed-off-by to this commit: 0d80044 Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
|
@joseph-isaacs I changed it. I was not sure if you wanted |
Merging this PR will degrade performance by 4.4%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | WallTime | arrow_checked_add_u32_neon[16384] |
13.3 µs | 20.3 µs | -34.48% |
| ⚡ | WallTime | arrow_checked_add_u32_avx512[16384] |
21.3 µs | 17.6 µs | +20.56% |
| ⚡ | WallTime | add_shapes_neon[(128, PerRowNullableConstant)] |
4 µs | 3.6 µs | +10.61% |
| 🆕 | Simulation | contains_absent[1048576] |
N/A | 776 µs | N/A |
| 🆕 | Simulation | contains_absent[256] |
N/A | 342.7 µs | N/A |
| 🆕 | Simulation | contains_absent[32768] |
N/A | 701.5 µs | N/A |
| 🆕 | Simulation | contains_present[1048576] |
N/A | 777.2 µs | N/A |
| 🆕 | Simulation | contains_present[256] |
N/A | 342.7 µs | N/A |
| 🆕 | Simulation | contains_present[32768] |
N/A | 690.9 µs | N/A |
| 🆕 | Simulation | insert[1048576] |
N/A | 783.2 µs | N/A |
| 🆕 | Simulation | insert[256] |
N/A | 347 µs | N/A |
| 🆕 | Simulation | insert[32768] |
N/A | 700.2 µs | N/A |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing joacoc:bloom-index (88b0e74) with develop (68e2aee)
Footnotes
-
106 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
-
4 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them. ↩
connortsui20
left a comment
There was a problem hiding this comment.
Generally looks good to me, but left some nits.
I verified the codegen and everything seems to vectorize correctly.
I'll let @joseph-isaacs give a final review to merge.
Thanks for the contribution!
|
Sorry to bring this late, but I just wanted to call it out before merging. There are two things I noticed when I jumped into the writer PR to wire everything together:
|
Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
…ew comments Similar to the scalar split, but for helpers in `BloomPartial` for `AggreateFnVTable`. It also addresses review comments around documentation and types. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
| #[cfg(test)] | ||
| impl From<Vec<[u32; 8]>> for BloomPartial { | ||
| fn from(value: Vec<[u32; 8]>) -> Self { | ||
| BloomPartial { | ||
| blocks: value, | ||
| hash_fn: HashFn::XxHash3_64, // Default. Only used for tests. | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl TryFrom<&[u8]> for BloomPartial { | ||
| type Error = VortexError; | ||
|
|
||
| /// Reconstructs a partial from its serialized byte representation | ||
| /// (the same layout produced by `to_scalar`). | ||
| fn try_from(bytes: &[u8]) -> VortexResult<Self> { | ||
| vortex_ensure!( | ||
| !bytes.is_empty() && bytes.len().is_multiple_of(BLOCK_SIZE), | ||
| "invalid bloom filter byte length: {}", | ||
| bytes.len() | ||
| ); | ||
|
|
||
| let blocks = bytes | ||
| .as_chunks::<BLOCK_SIZE>() | ||
| .0 |
There was a problem hiding this comment.
can you move this into a cfg[test] block with the test. And use a function not a trait this much work in a TryFrom is a footgun
| // This assumes that `other` was created using the same hash function as | ||
| // `partial`. Ideally, an assertion here about which `hash_fn` was used to create `other` | ||
| // would catch this invariant. |
There was a problem hiding this comment.
could at least have a debug assert?
There was a problem hiding this comment.
I can’t do that unless I add HashFn to the partial serialization as a header
This commit renames `hash_valid_scalar`, `contains_valid_scalar` and `insert_valid_scalar` to `hash_scalar`, `contains_scalar` and `insert_scalar`. `contain_scalar` and `insert_scalar` now can handle invalid scalars, and skip or return `false` depending the case. For the case of hashing an invalid scalar, it would panic rather than skipping, but that case is an invariant because both `contain_scalar` and `insert_scalar` have a guard check for nulls/invalids. Also this commit, removes the `insert_primitive` fn, and now primitives convert to bytes without any inline fn. Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
Signed-off-by: Joaquin Colacci <joaquincolacci@gmail.com>
Rationale for this change
This is part of a bigger issue around skip indexes but only focuses on the Bloom filter implementation:
ZonedLayout#8901What changes are included in this PR?
This is a continuation of a previous PR that already implements a simpler version of a bloom filter: #8933.
Most of the changes included in this PR are replacing the hand-rolled bloom-filter from the base PR with the split block bloom filter, and expanding support for more canonicals and constants. So far it supports bool, primitives, decimals, varbinview, and extensions, and this PR leaves out composable types out of the implementation (struct, list and so on).
About the code
The Bloom filter implementation is a translation from the paper source code, given that it is very short I thought it wouldn't be a big deal. The code doesn't use arch-y fn calls, but relies on the compiler optimizer for vectorizing/calling the correct assembler expression, which from my checks on godbolt, seems to get translated well. I could have gone into specializing the code into different architectures but keeping it as is seemed better for maintenance.
Accumulators
Bloom filter's accumulators for canonicals are very similar to those of the min/max aggregation fn. I used those implementations as a reference for manipulating each case, with some slight variations.
Invalid values are left out of Bloom filters. Only valid values apply. Accumulating an invalid value will just skip it, but asking for a scalar that is invalid will raise a
VortexError.Options/tuning
The Bloom filter has a single tuning option called
blocks_count. The more blocks, the more memory/space usage (per zone), but the fewer false positives. Defaults to 256 (8KiB p/zone), derived from the default amount of 8192 rows in a zone. The paper suggests keeping between 20 and 52 distinct hash values per block. Assuming all 8,192 values are distinct, choosing 32 values per block gives 8,192 / 32 = 256 blocks (low false positive rate: ~3%).Another approach could be to use statistics from a subset of the data, as is done with encoders, to select the best block count. I think this approach aligns with how Vortex encoders work, but it shouldn't be part of this PR, as it may depend on how the skip index writer is designed.
Hasher crate
This PR also includes a new crate
twox-hashthat implements XxHash, similar to how Parquet uses the same hasher for the same use case, but it relies on vendored code. Happy to change or look for alternatives.Pending tasks
XxHash3_64vsXXH64)