Skip to content

Commit 108240f

Browse files
HuaHuaYpitrou
andauthored
GH-50007: [C++][Parquet] Add bloom filter folding to automatically size SBBF filters (#50008)
### Rationale for this change This PR follows apache/arrow-rs#9628. It supports optimizing the disk usage of the Bloom filter. So specifying an ndv value larger than the actual value will not affect disk usage. > Bloom filters now support folding mode: allocate a conservatively large filter (sized for worst-case NDV), insert all values during writing, then fold down at flush time to meet a target FPP. This eliminates the need to guess NDV upfront and produces optimally-sized filters automatically. ### What changes are included in this PR? `BloomFilterBuilder` will try to fold the bloom filter before writing it to the output stream. ### Are these changes tested? Yes. ### Are there any user-facing changes? Yes. The type of `ndv` in `BloomFilterOptions` is changed from `int32_t` to `std::optional<int64_t>`. And the argument type of `OptimalNumOfBytes` and `OptimalNumOfBits` in `BlockSplitBloomFilter` is changed from `uint32_t ndv` to `uint64_t ndv`. Add a new field `fold` in `BloomFilterOptions` and default value is `true`. * GitHub Issue: #50007 Lead-authored-by: Zehua Zou <zehuazou2000@gmail.com> Co-authored-by: Antoine Pitrou <pitrou@free.fr> Signed-off-by: Antoine Pitrou <antoine@python.org>
1 parent a1f569c commit 108240f

6 files changed

Lines changed: 395 additions & 27 deletions

File tree

cpp/src/parquet/bloom_filter.cc

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18+
#include <algorithm>
19+
#include <bit>
20+
#include <cmath>
1821
#include <cstdint>
1922
#include <cstring>
2023
#include <limits>
2124
#include <memory>
2225

2326
#include "arrow/io/memory.h"
24-
#include "arrow/result.h"
27+
#include "arrow/util/bitmap_ops.h"
2528
#include "arrow/util/logging_internal.h"
2629
#include "arrow/util/macros.h"
2730

@@ -345,9 +348,90 @@ void BlockSplitBloomFilter::WriteTo(ArrowOutputStream* sink) const {
345348
PARQUET_THROW_NOT_OK(sink->Write(data_->data(), num_bytes_));
346349
}
347350

351+
void BlockSplitBloomFilter::FoldToTargetFpp(double target_fpp) {
352+
const auto num_bits = static_cast<int64_t>(num_bytes_) * 8;
353+
const auto total_set_bits =
354+
::arrow::internal::CountSetBits(data_->data(), /*bit_offset=*/0, num_bits);
355+
if (total_set_bits == 0) {
356+
num_bytes_ = kMinimumBloomFilterBytes;
357+
return;
358+
}
359+
360+
const double avg_fill = static_cast<double>(total_set_bits) / num_bits;
361+
const uint32_t num_folds = NumFoldsForTargetFpp(target_fpp, avg_fill);
362+
if (num_folds > 0) {
363+
Fold(num_folds);
364+
}
365+
}
366+
367+
uint32_t BlockSplitBloomFilter::NumFoldsForTargetFpp(double target_fpp,
368+
double avg_fill) const {
369+
const uint32_t num_blocks = NumBlocks();
370+
if (num_blocks < 2) {
371+
return 0;
372+
}
373+
// Number of blocks is a power of two
374+
DCHECK_EQ(num_blocks & (num_blocks - 1), 0);
375+
376+
// Estimate the fill rate after folding from the current average fill rate.
377+
// Folding ORs block groups together, so each fold changes the estimated fill rate
378+
// from f to 1 - (1 - f)^2. A membership check tests kBitsSetPerBlock bits, making
379+
// the estimated FPP equal to std::pow(folded_fill_rate, kBitsSetPerBlock).
380+
//
381+
// See also: Sailhan and Stehr, "Folding and Unfolding Bloom Filters", 2012:
382+
// https://hal.science/hal-01126174v1
383+
const auto max_folds = static_cast<uint32_t>(std::countr_zero(num_blocks));
384+
385+
uint32_t num_folds = 0;
386+
double unset_probability_after_folds = 1.0 - avg_fill;
387+
for (uint32_t i = 0; i < max_folds; ++i) {
388+
unset_probability_after_folds *= unset_probability_after_folds;
389+
const double folded_fill_rate = 1.0 - unset_probability_after_folds;
390+
const double estimated_fpp = std::pow(folded_fill_rate, kBitsSetPerBlock);
391+
if (estimated_fpp > target_fpp) {
392+
break;
393+
}
394+
++num_folds;
395+
}
396+
return num_folds;
397+
}
398+
399+
void BlockSplitBloomFilter::Fold(uint32_t num_folds) {
400+
DCHECK_GT(num_folds, 0);
401+
402+
const uint32_t num_blocks = NumBlocks();
403+
// A fold group is a consecutive run of blocks ORed into one output block.
404+
// Keeping the group size as (1 << num_folds) preserves a power-of-two bitset
405+
// size. Folding by this power-of-two group size keeps the old-to-new bucket
406+
// remapping aligned with bucket lookup and avoids false negatives.
407+
const uint32_t group_size = UINT32_C(1) << num_folds;
408+
DCHECK_LE(group_size, num_blocks);
409+
410+
const uint32_t new_num_blocks = num_blocks / group_size;
411+
auto* bitset32 = reinterpret_cast<uint32_t*>(data_->mutable_data());
412+
413+
for (uint32_t dst_block = 0; dst_block < new_num_blocks; ++dst_block) {
414+
uint32_t* dst = bitset32 + dst_block * kBitsSetPerBlock;
415+
416+
const uint32_t src_block = dst_block * group_size;
417+
const uint32_t* src = bitset32 + src_block * kBitsSetPerBlock;
418+
if (dst != src) {
419+
std::copy_n(src, kBitsSetPerBlock, dst);
420+
}
421+
422+
for (uint32_t fold_block = 1; fold_block < group_size; ++fold_block) {
423+
src = bitset32 + (src_block + fold_block) * kBitsSetPerBlock;
424+
for (int word = 0; word < kBitsSetPerBlock; ++word) {
425+
dst[word] |= src[word];
426+
}
427+
}
428+
}
429+
430+
num_bytes_ = new_num_blocks * kBytesPerFilterBlock;
431+
}
432+
348433
bool BlockSplitBloomFilter::FindHash(uint64_t hash) const {
349-
const uint32_t bucket_index =
350-
static_cast<uint32_t>(((hash >> 32) * (num_bytes_ / kBytesPerFilterBlock)) >> 32);
434+
const uint32_t bucket_index = static_cast<uint32_t>(((hash >> 32) * NumBlocks()) >> 32);
351435
const uint32_t key = static_cast<uint32_t>(hash);
352436
const uint32_t* bitset32 = reinterpret_cast<const uint32_t*>(data_->data());
353437

@@ -363,8 +447,7 @@ bool BlockSplitBloomFilter::FindHash(uint64_t hash) const {
363447
}
364448

365449
void BlockSplitBloomFilter::InsertHashImpl(uint64_t hash) {
366-
const uint32_t bucket_index =
367-
static_cast<uint32_t>(((hash >> 32) * (num_bytes_ / kBytesPerFilterBlock)) >> 32);
450+
const uint32_t bucket_index = static_cast<uint32_t>(((hash >> 32) * NumBlocks()) >> 32);
368451
const uint32_t key = static_cast<uint32_t>(hash);
369452
uint32_t* bitset32 = reinterpret_cast<uint32_t*>(data_->mutable_data());
370453

cpp/src/parquet/bloom_filter.h

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ class PARQUET_EXPORT BlockSplitBloomFilter : public BloomFilter {
230230
/// @param fpp The false positive probability.
231231
/// @return it always return a value between kMinimumBloomFilterBytes and
232232
/// kMaximumBloomFilterBytes, and the return value is always a power of 2
233-
static uint32_t OptimalNumOfBytes(uint32_t ndv, double fpp) {
233+
static uint32_t OptimalNumOfBytes(uint64_t ndv, double fpp) {
234234
uint32_t optimal_num_of_bits = OptimalNumOfBits(ndv, fpp);
235235
ARROW_DCHECK(::arrow::bit_util::IsMultipleOf8(optimal_num_of_bits));
236236
return optimal_num_of_bits >> 3;
@@ -243,7 +243,7 @@ class PARQUET_EXPORT BlockSplitBloomFilter : public BloomFilter {
243243
/// @param fpp The false positive probability.
244244
/// @return it always return a value between kMinimumBloomFilterBytes * 8 and
245245
/// kMaximumBloomFilterBytes * 8, and the return value is always a power of 16
246-
static uint32_t OptimalNumOfBits(uint32_t ndv, double fpp) {
246+
static uint32_t OptimalNumOfBits(uint64_t ndv, double fpp) {
247247
ARROW_DCHECK(fpp > 0.0 && fpp < 1.0);
248248
const double m = -8.0 * ndv / log(1 - pow(fpp, 1.0 / 8));
249249
uint32_t num_bits;
@@ -276,6 +276,9 @@ class PARQUET_EXPORT BlockSplitBloomFilter : public BloomFilter {
276276
bool FindHash(uint64_t hash) const override;
277277
void InsertHash(uint64_t hash) override;
278278
void InsertHashes(const uint64_t* hashes, int num_values) override;
279+
/// Fold the bloom filter down to the smallest size that still meets the target FPP
280+
/// (False Positive Probability).
281+
void FoldToTargetFpp(double target_fpp);
279282
void WriteTo(ArrowOutputStream* sink) const override;
280283
uint32_t GetBitsetSize() const override { return num_bytes_; }
281284

@@ -350,6 +353,12 @@ class PARQUET_EXPORT BlockSplitBloomFilter : public BloomFilter {
350353

351354
private:
352355
inline void InsertHashImpl(uint64_t hash);
356+
uint32_t NumBlocks() const {
357+
ARROW_DCHECK_EQ(num_bytes_ % kBytesPerFilterBlock, 0);
358+
return num_bytes_ / kBytesPerFilterBlock;
359+
}
360+
uint32_t NumFoldsForTargetFpp(double target_fpp, double avg_fill) const;
361+
void Fold(uint32_t num_folds);
353362

354363
// Bytes in a tiny Bloom filter block.
355364
static constexpr int kBytesPerFilterBlock = 32;

cpp/src/parquet/bloom_filter_reader_writer_test.cc

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ TEST(BloomFilterBuilder, BasicRoundTrip) {
9191
"schema", Repetition::REPEATED, {schema::ByteArray("c1"), schema::ByteArray("c2")});
9292
schema.Init(root);
9393

94-
BloomFilterOptions bloom_filter_options{100, 0.05};
94+
BloomFilterOptions bloom_filter_options{.ndv = 100, .fpp = 0.05};
9595
const auto bitset_size = BlockSplitBloomFilter::OptimalNumOfBytes(
96-
bloom_filter_options.ndv, bloom_filter_options.fpp);
96+
bloom_filter_options.ndv.value(), bloom_filter_options.fpp);
9797
WriterProperties::Builder properties_builder;
9898
properties_builder.enable_bloom_filter("c1", bloom_filter_options);
9999
auto writer_properties = properties_builder.build();
@@ -150,6 +150,115 @@ TEST(BloomFilterBuilder, BasicRoundTrip) {
150150
}
151151
}
152152

153+
namespace {
154+
155+
struct BloomFilterBuilderFoldingTestCase {
156+
int64_t ndv;
157+
bool fold;
158+
int32_t inserted_count;
159+
int64_t expected_bitset_ndv;
160+
};
161+
162+
class BloomFilterBuilderFoldingTest
163+
: public ::testing::TestWithParam<BloomFilterBuilderFoldingTestCase> {};
164+
165+
} // namespace
166+
167+
TEST_P(BloomFilterBuilderFoldingTest, RespectsOption) {
168+
const auto& test_case = GetParam();
169+
170+
SchemaDescriptor schema;
171+
schema::NodePtr root =
172+
schema::GroupNode::Make("schema", Repetition::REPEATED, {schema::ByteArray("c1")});
173+
schema.Init(root);
174+
175+
constexpr double kFpp = 0.05;
176+
BloomFilterOptions bloom_filter_options{
177+
.ndv = test_case.ndv, .fpp = kFpp, .fold = test_case.fold};
178+
const auto initial_bitset_size = BlockSplitBloomFilter::OptimalNumOfBytes(
179+
bloom_filter_options.ndv.value(), bloom_filter_options.fpp);
180+
WriterProperties::Builder properties_builder;
181+
properties_builder.enable_bloom_filter("c1", bloom_filter_options);
182+
auto writer_properties = properties_builder.build();
183+
auto bloom_filter_builder = BloomFilterBuilder::Make(&schema, writer_properties.get());
184+
185+
bloom_filter_builder->AppendRowGroup();
186+
auto bloom_filter = bloom_filter_builder->CreateBloomFilter(/*column_ordinal=*/0);
187+
ASSERT_NE(bloom_filter, nullptr);
188+
ASSERT_EQ(initial_bitset_size, bloom_filter->GetBitsetSize());
189+
190+
std::vector<uint64_t> hashes;
191+
hashes.reserve(test_case.inserted_count);
192+
for (int32_t i = 0; i < test_case.inserted_count; ++i) {
193+
const auto hash = bloom_filter->Hash(i);
194+
hashes.push_back(hash);
195+
bloom_filter->InsertHash(hash);
196+
}
197+
198+
auto sink = CreateOutputStream();
199+
auto locations = bloom_filter_builder->WriteTo(sink.get());
200+
ASSERT_EQ(locations.size(), 1);
201+
ASSERT_OK_AND_ASSIGN(auto buffer, sink->Finish());
202+
203+
const auto& location = locations.front().second;
204+
ReaderProperties reader_properties;
205+
::arrow::io::BufferReader reader(
206+
::arrow::SliceBuffer(buffer, location.offset, location.length));
207+
auto filter = parquet::BlockSplitBloomFilter::Deserialize(reader_properties, &reader);
208+
209+
const auto actual_bitset_size = filter.GetBitsetSize();
210+
EXPECT_EQ(BlockSplitBloomFilter::OptimalNumOfBytes(test_case.expected_bitset_ndv, kFpp),
211+
actual_bitset_size);
212+
213+
if (test_case.fold) {
214+
EXPECT_LE(actual_bitset_size, initial_bitset_size);
215+
} else {
216+
EXPECT_EQ(actual_bitset_size, initial_bitset_size);
217+
}
218+
219+
for (uint64_t hash : hashes) {
220+
EXPECT_TRUE(filter.FindHash(hash));
221+
}
222+
223+
int32_t false_positives = 0;
224+
constexpr int32_t kNonInsertedCount = 10'000;
225+
for (int32_t i = test_case.inserted_count;
226+
i < test_case.inserted_count + kNonInsertedCount; ++i) {
227+
false_positives += filter.FindHash(filter.Hash(i));
228+
}
229+
const auto sample_fpp = static_cast<double>(false_positives) / kNonInsertedCount;
230+
EXPECT_LT(sample_fpp, kFpp);
231+
232+
if (test_case.fold && test_case.inserted_count > 0) {
233+
// If the actual fpp, as computed on this sample, is significantly below kFpp / 2,
234+
// then we could have folded the bloom filter at least once more.
235+
EXPECT_GT(sample_fpp, kFpp / 2.1);
236+
}
237+
}
238+
239+
INSTANTIATE_TEST_SUITE_P(
240+
BloomFilterBuilder, BloomFilterBuilderFoldingTest,
241+
::testing::Values(BloomFilterBuilderFoldingTestCase{.ndv = 1'000'000,
242+
.fold = true,
243+
.inserted_count = 1000,
244+
.expected_bitset_ndv = 1000},
245+
BloomFilterBuilderFoldingTestCase{.ndv = 1'000'000,
246+
.fold = false,
247+
.inserted_count = 1000,
248+
.expected_bitset_ndv = 1'000'000},
249+
BloomFilterBuilderFoldingTestCase{.ndv = 1024,
250+
.fold = true,
251+
.inserted_count = 1024,
252+
.expected_bitset_ndv = 1024},
253+
BloomFilterBuilderFoldingTestCase{.ndv = 1024,
254+
.fold = true,
255+
.inserted_count = 0,
256+
.expected_bitset_ndv = 0},
257+
BloomFilterBuilderFoldingTestCase{.ndv = 1024,
258+
.fold = false,
259+
.inserted_count = 0,
260+
.expected_bitset_ndv = 1024}));
261+
153262
TEST(BloomFilterBuilder, InvalidOperations) {
154263
SchemaDescriptor schema;
155264
schema::NodePtr root = schema::GroupNode::Make(
@@ -158,7 +267,7 @@ TEST(BloomFilterBuilder, InvalidOperations) {
158267
schema.Init(root);
159268

160269
WriterProperties::Builder properties_builder;
161-
BloomFilterOptions bloom_filter_options{100, 0.05};
270+
BloomFilterOptions bloom_filter_options{.ndv = 100, .fpp = 0.05};
162271
properties_builder.enable_bloom_filter("c1", bloom_filter_options);
163272
properties_builder.enable_bloom_filter("c2", bloom_filter_options);
164273
auto properties = properties_builder.build();

cpp/src/parquet/bloom_filter_writer.cc

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,16 @@ class BloomFilterBuilderImpl : public BloomFilterBuilder {
185185
const WriterProperties* properties_;
186186
bool finished_ = false;
187187

188-
using RowGroupBloomFilters =
189-
std::map</*column_id=*/int32_t, std::shared_ptr<BloomFilter>>;
188+
struct RowGroupBloomFilters {
189+
struct BloomFilterEntry {
190+
std::shared_ptr<BlockSplitBloomFilter> filter;
191+
double target_fpp;
192+
bool try_fold;
193+
};
194+
195+
std::map</*column_id=*/int32_t, BloomFilterEntry> entries;
196+
};
197+
190198
std::vector<RowGroupBloomFilters> bloom_filters_; // indexed by row group ordinal
191199
};
192200

@@ -206,17 +214,23 @@ BloomFilter* BloomFilterBuilderImpl::CreateBloomFilter(int32_t column_ordinal) {
206214

207215
CheckState(column_ordinal);
208216

209-
auto& curr_rg_bfs = *bloom_filters_.rbegin();
217+
auto& curr_rg_bfs = bloom_filters_.back().entries;
210218
if (curr_rg_bfs.find(column_ordinal) != curr_rg_bfs.cend()) {
211219
std::stringstream ss;
212220
ss << "Bloom filter already exists for column: " << column_ordinal
213221
<< ", row group: " << (bloom_filters_.size() - 1);
214222
throw ParquetException(ss.str());
215223
}
216224

217-
auto bf = std::make_unique<BlockSplitBloomFilter>(properties_->memory_pool());
218-
bf->Init(BlockSplitBloomFilter::OptimalNumOfBytes(opts->ndv, opts->fpp));
219-
return curr_rg_bfs.emplace(column_ordinal, std::move(bf)).first->second.get();
225+
ARROW_DCHECK(opts->ndv.has_value());
226+
auto bf = std::make_shared<BlockSplitBloomFilter>(properties_->memory_pool());
227+
bf->Init(BlockSplitBloomFilter::OptimalNumOfBytes(opts->ndv.value(), opts->fpp));
228+
return curr_rg_bfs
229+
.emplace(
230+
column_ordinal,
231+
RowGroupBloomFilters::BloomFilterEntry{
232+
.filter = std::move(bf), .target_fpp = opts->fpp, .try_fold = opts->fold})
233+
.first->second.filter.get();
220234
}
221235

222236
IndexLocations BloomFilterBuilderImpl::WriteTo(::arrow::io::OutputStream* sink) {
@@ -228,11 +242,14 @@ IndexLocations BloomFilterBuilderImpl::WriteTo(::arrow::io::OutputStream* sink)
228242
IndexLocations locations;
229243

230244
for (size_t i = 0; i != bloom_filters_.size(); ++i) {
231-
auto& row_group_bloom_filters = bloom_filters_[i];
232-
for (const auto& [column_id, filter] : row_group_bloom_filters) {
245+
auto& row_group_bloom_filters = bloom_filters_[i].entries;
246+
for (auto& [column_id, entry] : row_group_bloom_filters) {
233247
// TODO(GH-43138): Determine the quality of bloom filter before writing it.
234248
PARQUET_ASSIGN_OR_THROW(int64_t offset, sink->Tell());
235-
filter->WriteTo(sink);
249+
if (entry.try_fold) {
250+
entry.filter->FoldToTargetFpp(entry.target_fpp);
251+
}
252+
entry.filter->WriteTo(sink);
236253
PARQUET_ASSIGN_OR_THROW(int64_t pos, sink->Tell());
237254

238255
if (pos - offset > std::numeric_limits<int32_t>::max()) {

0 commit comments

Comments
 (0)