From 95fe8e8406448c3cd757644854100984eeeac5f8 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 9 Nov 2024 00:11:28 +0000 Subject: [PATCH 01/82] Initial stuff for reading bloom filter from PQ files --- .../io/parquet/compact_protocol_reader.cpp | 33 +++- .../io/parquet/compact_protocol_reader.hpp | 4 + cpp/src/io/parquet/parquet.hpp | 45 +++++ .../io/parquet/reader_apply_bloom_filters.cu | 36 ++++ cpp/src/io/parquet/reader_impl_helpers.cpp | 178 ++++++++++++++++++ cpp/src/io/parquet/reader_impl_helpers.hpp | 25 ++- cpp/src/io/parquet/reader_impl_preprocess.cu | 3 +- 7 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 cpp/src/io/parquet/reader_apply_bloom_filters.cu diff --git a/cpp/src/io/parquet/compact_protocol_reader.cpp b/cpp/src/io/parquet/compact_protocol_reader.cpp index d276e946a51..55874822ade 100644 --- a/cpp/src/io/parquet/compact_protocol_reader.cpp +++ b/cpp/src/io/parquet/compact_protocol_reader.cpp @@ -655,6 +655,33 @@ void CompactProtocolReader::read(ColumnChunk* c) function_builder(this, op); } +void CompactProtocolReader::read(BloomFilterAlgorithm* alg) +{ + auto op = std::make_tuple(parquet_field_union_enumerator(1, alg->algorithm)); + function_builder(this, op); +} + +void CompactProtocolReader::read(BloomFilterHash* hash) +{ + auto op = std::make_tuple(parquet_field_union_enumerator(1, hash->hash)); + function_builder(this, op); +} + +void CompactProtocolReader::read(BloomFilterCompression* comp) +{ + auto op = std::make_tuple(parquet_field_union_enumerator(1, comp->compression)); + function_builder(this, op); +} + +void CompactProtocolReader::read(BloomFilterHeader* bf) +{ + auto op = std::make_tuple(parquet_field_int32(1, bf->num_bytes), + parquet_field_struct(2, bf->algorithm), + parquet_field_struct(3, bf->hash), + parquet_field_struct(4, bf->compression)); + function_builder(this, op); +} + void CompactProtocolReader::read(ColumnChunkMetaData* c) { using optional_size_statistics = @@ -662,7 +689,9 @@ void CompactProtocolReader::read(ColumnChunkMetaData* c) using optional_list_enc_stats = parquet_field_optional, parquet_field_struct_list>; - auto op = std::make_tuple(parquet_field_enum(1, c->type), + using optional_i64 = parquet_field_optional; + using optional_i32 = parquet_field_optional; + auto op = std::make_tuple(parquet_field_enum(1, c->type), parquet_field_enum_list(2, c->encodings), parquet_field_string_list(3, c->path_in_schema), parquet_field_enum(4, c->codec), @@ -674,6 +703,8 @@ void CompactProtocolReader::read(ColumnChunkMetaData* c) parquet_field_int64(11, c->dictionary_page_offset), parquet_field_struct(12, c->statistics), optional_list_enc_stats(13, c->encoding_stats), + optional_i64(14, c->bloom_filter_offset), + optional_i32(15, c->bloom_filter_length), optional_size_statistics(16, c->size_statistics)); function_builder(this, op); } diff --git a/cpp/src/io/parquet/compact_protocol_reader.hpp b/cpp/src/io/parquet/compact_protocol_reader.hpp index b87f2e9c692..3d39daa60d1 100644 --- a/cpp/src/io/parquet/compact_protocol_reader.hpp +++ b/cpp/src/io/parquet/compact_protocol_reader.hpp @@ -108,6 +108,10 @@ class CompactProtocolReader { void read(IntType* t); void read(RowGroup* r); void read(ColumnChunk* c); + void read(BloomFilterAlgorithm* bf); + void read(BloomFilterHash* bf); + void read(BloomFilterCompression* bf); + void read(BloomFilterHeader* bf); void read(ColumnChunkMetaData* c); void read(PageHeader* p); void read(DataPageHeader* d); diff --git a/cpp/src/io/parquet/parquet.hpp b/cpp/src/io/parquet/parquet.hpp index 2851ef67a65..118ec43133b 100644 --- a/cpp/src/io/parquet/parquet.hpp +++ b/cpp/src/io/parquet/parquet.hpp @@ -382,12 +382,57 @@ struct ColumnChunkMetaData { // Set of all encodings used for pages in this column chunk. This information can be used to // determine if all data pages are dictionary encoded for example. std::optional> encoding_stats; + // Byte offset from beginning of file to Bloom filter data. + std::optional bloom_filter_offset; + // Size of Bloom filter data including the serialized header, in bytes. Added in 2.10 so readers + // may not read this field from old files and it can be obtained after the BloomFilterHeader has + // been deserialized. Writers should write this field so readers can read the bloom filter in a + // single I/O. + std::optional bloom_filter_length; // Optional statistics to help estimate total memory when converted to in-memory representations. // The histograms contained in these statistics can also be useful in some cases for more // fine-grained nullability/list length filter pushdown. std::optional size_statistics; }; +/** + * @brief The algorithm used in Bloom filter. + **/ +struct BloomFilterAlgorithm { + /** Block-based Bloom filter. **/ + enum Algorithm { UNDEFINED, SPLIT_BLOCK }; + Algorithm algorithm{Algorithm::SPLIT_BLOCK}; +}; + +/** + * @brief The hash function used in Bloom filter. This function takes the hash of a column value + * using plain encoding. + **/ +struct BloomFilterHash { + /** xxHash Strategy. **/ + enum Hash { UNDEFINED, XXHASH }; + Hash hash{Hash::XXHASH}; +}; + +/** + * @brief The compression used in the Bloom filter. + **/ +struct BloomFilterCompression { + enum Compression { UNDEFINED, UNCOMPRESSED }; + Compression compression{Compression::UNCOMPRESSED}; +}; + +struct BloomFilterHeader { + // The size of bitset in bytes + int32_t num_bytes; + // The algorithm for setting bits. * + BloomFilterAlgorithm algorithm; + // The hash function used for Bloom filter. * + BloomFilterHash hash; + // The compression used in the Bloom filter * + BloomFilterCompression compression; +}; + /** * @brief Thrift-derived struct describing a chunk of data for a particular * column diff --git a/cpp/src/io/parquet/reader_apply_bloom_filters.cu b/cpp/src/io/parquet/reader_apply_bloom_filters.cu new file mode 100644 index 00000000000..2d1c20ba48c --- /dev/null +++ b/cpp/src/io/parquet/reader_apply_bloom_filters.cu @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file bloom_filter_reader.cu + * @brief Bloom filter reader based row group filtration implementation + */ + +#include "parquet.hpp" +#include "parquet_common.hpp" + +#include + +#include + +#include +#include +#include +#include +#include + +// TODO: Implement this +cuda::std::optional>> apply_bloom_filters() { return {}; } \ No newline at end of file diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index a6562d33de2..b6348a3f897 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -25,11 +25,16 @@ #include +#include +#include + #include #include #include +#include #include +#include #include namespace cudf::io::parquet::detail { @@ -1028,8 +1033,169 @@ std::vector aggregate_reader_metadata::get_pandas_index_names() con return names; } +/** + * @brief Asynchronously reads bloom filters to device. + * + * @param sources Dataset sources + * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param chunks List of chunk indices to read + * @param bloom_filter_offsets Bloom filter offsets for all chunks + * @param bloom_filter_sizes Bloom filter sizes for all chunks + * @param chunk_source_map Association between each column chunk and its source + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return A future object for reading synchronization + */ +std::future read_bloom_filters_async( + host_span const> sources, + cudf::host_span bloom_filter_data, + host_span chunks, + cudf::host_span> bloom_filter_offsets, + cudf::host_span> bloom_filter_sizes, + std::vector const& chunk_source_map, + rmm::cuda_stream_view stream) +{ + // Transfer bloom filter data + std::vector> read_tasks; + + // FIXME: Do not read all chunks. Instead use a list of chunks to read. + for (auto chunk : chunks) { + // Read bloom filter if present + if (bloom_filter_offsets[chunk].has_value()) { + auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); + // If Bloom filter size (header + bitset) is available, just read the entire thing. + // Else just read 256 bytes which will contain the entire header and may contain the + // entire bitset as well. + auto constexpr bloom_filter_header_size_guess = 256; + auto const initial_read_size = + bloom_filter_sizes[chunk].value_or(bloom_filter_header_size_guess); + + // Read an initial buffer from source + auto& source = sources[chunk_source_map[chunk]]; + auto buffer = source->host_read(bloom_filter_offset, initial_read_size); + + // Deserialize the Bloom filter header from the buffer. + BloomFilterHeader header; + CompactProtocolReader cp{buffer->data(), buffer->size()}; + cp.read(&header); + + // Bloom filter header size + auto const bloom_filter_header_size = static_cast(cp.bytecount()); + auto const bitset_size = header.num_bytes; + + // Check if we already read in the filter bitset in the initial read. + if (initial_read_size >= bloom_filter_header_size + bitset_size) { + bloom_filter_data[chunk] = + rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); + } + // Read the bitset from datasource. + else { + auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; + // Directly read to device if preferred + if (source->is_device_read_preferred(bitset_size)) { + bloom_filter_data[chunk] = rmm::device_buffer(bitset_size, stream); + auto future_read_size = + source->device_read_async(bitset_offset, + bitset_size, + static_cast(bloom_filter_data[chunk].data()), + stream); + + read_tasks.emplace_back(std::move(future_read_size)); + } else { + buffer = source->host_read(bitset_offset, bitset_size); + bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); + } + } + } + } + auto sync_fn = [](decltype(read_tasks) read_tasks) { + for (auto& task : read_tasks) { + task.wait(); + } + }; + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); +} + +std::optional>> +aggregate_reader_metadata::apply_bloom_filter_to_row_groups( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + rmm::cuda_stream_view stream) const +{ + // Number of row groups after filter_row_groups(). + auto const num_row_groups = std::accumulate( + row_group_indices.begin(), row_group_indices.end(), 0, [](auto& sum, auto const& rgis) { + return sum + rgis.size(); + }); + // Descriptors for all the chunks that make up the selected columns + auto const num_input_columns = output_dtypes.size(); + auto const num_chunks = num_row_groups * num_input_columns; + + // Association between each column chunk and its source + std::vector chunk_source_map(num_chunks); + + // Keep track of column chunk file offsets + std::vector> bloom_filter_offsets(num_chunks); + std::vector> bloom_filter_sizes(num_chunks); + + // List of chunks to read + std::vector chunks_to_read{}; + chunks_to_read.reserve(num_chunks); + + size_type chunk_count = 0; + + // FIXME: We don't need to read bloom filters for all chunks. Only the ones belonging to the + // columns of our interest (equality predicate). + // For all data sources + for (size_t rg_source_index = 0; rg_source_index < row_group_indices.size(); rg_source_index++) { + auto const& rg_index = row_group_indices[rg_source_index]; + // For all row groups in a data source + for (auto const rgi : rg_index) { + // For all column chunks in a row group + for (size_t col_idx = 0; col_idx < output_dtypes.size(); col_idx++) { + auto const schema_idx = output_column_schemas[col_idx]; + auto& col_meta = get_column_metadata(rgi, rg_source_index, schema_idx); + + // Bloom filter offsets and sizes + bloom_filter_offsets[chunk_count] = col_meta.bloom_filter_offset; + bloom_filter_sizes[chunk_count] = col_meta.bloom_filter_length; + chunks_to_read.emplace_back(chunk_count); + + // Map each column chunk to its column index and its source index + chunk_source_map[chunk_count] = rg_source_index; + chunk_count++; + } + } + } + + // Do we have any bloom filters + if (std::any_of(bloom_filter_offsets.cbegin(), + bloom_filter_offsets.cend(), + [](auto const offset) { return offset.has_value(); })) { + // Initialize the vector of bloom filter data buffers + std::vector bloom_filter_data(num_chunks); + + // Wait on bloom filter read tasks + read_bloom_filters_async(sources, + bloom_filter_data, + chunks_to_read, + bloom_filter_offsets, + bloom_filter_sizes, + chunk_source_map, + stream) + .wait(); + + // Apply bloom filter to row groups + // return apply_bloom_filters(); + } + return {}; +} + std::tuple, std::vector> aggregate_reader_metadata::select_row_groups( + host_span const> sources, host_span const> row_group_indices, int64_t skip_rows_opt, std::optional const& num_rows_opt, @@ -1048,6 +1214,18 @@ aggregate_reader_metadata::select_row_groups( host_span const>(filtered_row_group_indices.value()); } } + + // FIXME: Provide the actual condition for this if + if (true /* equality predicate provided */) { + filtered_row_group_indices = apply_bloom_filter_to_row_groups( + sources, row_group_indices, output_dtypes, output_column_schemas, stream); + + if (filtered_row_group_indices.has_value()) { + row_group_indices = + host_span const>(filtered_row_group_indices.value()); + } + } + std::vector selection; auto [rows_to_skip, rows_to_read] = [&]() { if (not row_group_indices.empty()) { return std::pair{}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index fd692c0cdd6..82228599cd4 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -343,6 +343,7 @@ class aggregate_reader_metadata { * The input `row_start` and `row_count` parameters will be recomputed and output as the valid * values based on the input row group list. * + * @param sources Lists of input datasources. * @param row_group_indices Lists of row groups to read, one per source * @param row_start Starting row of the selection * @param row_count Total number of rows selected @@ -354,7 +355,8 @@ class aggregate_reader_metadata { * starting row, and list of number of rows per source. */ [[nodiscard]] std::tuple, std::vector> - select_row_groups(host_span const> row_group_indices, + select_row_groups(host_span const> sources, + host_span const> row_group_indices, int64_t row_start, std::optional const& row_count, host_span output_dtypes, @@ -362,6 +364,27 @@ class aggregate_reader_metadata { std::optional> filter, rmm::cuda_stream_view stream) const; + /** + * @brief Filters the row groups using bloom filters + * + * @param sources Dataset sources + * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param begin_chunk Index of first column chunk to read + * @param end_chunk Index after the last column chunk to read + * @param bloom_filter_offsets Bloom filter offsets for all chunks + * @param bloom_filter_sizes Bloom filter sizes for all chunks + * @param chunk_source_map Association between each column chunk and its source + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return Filtered row group indices, if any is filtered. + */ + std::optional>> apply_bloom_filter_to_row_groups( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + rmm::cuda_stream_view stream) const; + /** * @brief Filters and reduces down to a selection of columns * diff --git a/cpp/src/io/parquet/reader_impl_preprocess.cu b/cpp/src/io/parquet/reader_impl_preprocess.cu index f03f1214b9a..7c169f0e6ca 100644 --- a/cpp/src/io/parquet/reader_impl_preprocess.cu +++ b/cpp/src/io/parquet/reader_impl_preprocess.cu @@ -1281,7 +1281,8 @@ void reader::impl::preprocess_file(read_mode mode) _file_itm_data.global_num_rows, _file_itm_data.row_groups, _file_itm_data.num_rows_per_source) = - _metadata->select_row_groups(_options.row_group_indices, + _metadata->select_row_groups(_sources, + _options.row_group_indices, _options.skip_rows, _options.num_rows, output_dtypes, From 4f0e7ab2b309a9cf7cb552523850fd4298df76c4 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 9 Nov 2024 00:22:21 +0000 Subject: [PATCH 02/82] Minor bug fix --- cpp/src/io/parquet/reader_impl_helpers.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index b6348a3f897..da000edfd04 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1215,12 +1215,13 @@ aggregate_reader_metadata::select_row_groups( } } - // FIXME: Provide the actual condition for this if + // FIXME: Provide the actual condition for this if (true /* equality predicate provided */) { - filtered_row_group_indices = apply_bloom_filter_to_row_groups( + auto const bloom_filtered_row_groups = apply_bloom_filter_to_row_groups( sources, row_group_indices, output_dtypes, output_column_schemas, stream); - - if (filtered_row_group_indices.has_value()) { + // TODO: Can use a better logic here. + if (bloom_filtered_row_groups.has_value()) { + filtered_row_group_indices.value() = std::move(bloom_filtered_row_groups.value()); row_group_indices = host_span const>(filtered_row_group_indices.value()); } From 48a50c401aafb12bf7f29645092601b174a822f8 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 9 Nov 2024 00:22:55 +0000 Subject: [PATCH 03/82] Apply style fix --- cpp/src/io/parquet/reader_apply_bloom_filters.cu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/reader_apply_bloom_filters.cu b/cpp/src/io/parquet/reader_apply_bloom_filters.cu index 2d1c20ba48c..fc85a0a1eb2 100644 --- a/cpp/src/io/parquet/reader_apply_bloom_filters.cu +++ b/cpp/src/io/parquet/reader_apply_bloom_filters.cu @@ -33,4 +33,4 @@ #include // TODO: Implement this -cuda::std::optional>> apply_bloom_filters() { return {}; } \ No newline at end of file +cuda::std::optional>> apply_bloom_filters() { return {}; } From 68be24f204857c75ea10b85aebf6209d50677929 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 03:12:55 +0000 Subject: [PATCH 04/82] Some updates --- cpp/src/io/parquet/predicate_pushdown.cpp | 14 +++++++ cpp/src/io/parquet/reader_impl_helpers.cpp | 44 +++++++++++++--------- cpp/src/io/parquet/reader_impl_helpers.hpp | 2 + 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index cd3dcd2bce4..abc8978d8db 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -391,6 +391,7 @@ class stats_expression_converter : public ast::detail::expression_transformer { } // namespace std::optional>> aggregate_reader_metadata::filter_row_groups( + host_span const> sources, host_span const> row_group_indices, host_span output_dtypes, host_span output_column_schemas, @@ -491,6 +492,19 @@ std::optional>> aggregate_reader_metadata::fi } filtered_row_group_indices.push_back(std::move(filtered_row_groups)); } + + // If equality predicate + if (true /* is_equality_predicate */) { + filtered_row_group_indices = + apply_bloom_filter_to_row_groups( + sources, + filtered_row_group_indices.empty() ? input_row_group_indices : filtered_row_group_indices, + output_dtypes, + output_column_schemas, + stream) + .value(); + } + return {std::move(filtered_row_group_indices)}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index da000edfd04..7ca1ffc4253 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1079,6 +1079,20 @@ std::future read_bloom_filters_async( CompactProtocolReader cp{buffer->data(), buffer->size()}; cp.read(&header); + // Test if header is valid. + auto const is_header_valid = + (header.num_bytes % 32) == 0 and + header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and + header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and + header.hash.hash == BloomFilterHash::Hash::XXHASH; + + if (not is_header_valid) { + // Simply use an empty device buffer and move on. + bloom_filter_data[chunk] = rmm::device_buffer{}; + CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); + continue; + } + // Bloom filter header size auto const bloom_filter_header_size = static_cast(cp.bytecount()); auto const bitset_size = header.num_bytes; @@ -1106,6 +1120,9 @@ std::future read_bloom_filters_async( bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); } } + } else { + // Simply use an empty device buffer. + bloom_filter_data[chunk] = rmm::device_buffer{}; } } auto sync_fn = [](decltype(read_tasks) read_tasks) { @@ -1124,11 +1141,16 @@ aggregate_reader_metadata::apply_bloom_filter_to_row_groups( host_span output_column_schemas, rmm::cuda_stream_view stream) const { - // Number of row groups after filter_row_groups(). - auto const num_row_groups = std::accumulate( - row_group_indices.begin(), row_group_indices.end(), 0, [](auto& sum, auto const& rgis) { - return sum + rgis.size(); - }); + // Create row group indices. + // std::vector> filtered_row_group_indices; + // Number of total row groups to process. + auto const num_row_groups = std::accumulate(row_group_indices.begin(), + row_group_indices.end(), + 0, + [](size_type sum, auto const& per_file_row_groups) { + return sum + per_file_row_groups.size(); + }); + // Descriptors for all the chunks that make up the selected columns auto const num_input_columns = output_dtypes.size(); auto const num_chunks = num_row_groups * num_input_columns; @@ -1215,18 +1237,6 @@ aggregate_reader_metadata::select_row_groups( } } - // FIXME: Provide the actual condition for this - if (true /* equality predicate provided */) { - auto const bloom_filtered_row_groups = apply_bloom_filter_to_row_groups( - sources, row_group_indices, output_dtypes, output_column_schemas, stream); - // TODO: Can use a better logic here. - if (bloom_filtered_row_groups.has_value()) { - filtered_row_group_indices.value() = std::move(bloom_filtered_row_groups.value()); - row_group_indices = - host_span const>(filtered_row_group_indices.value()); - } - } - std::vector selection; auto [rows_to_skip, rows_to_read] = [&]() { if (not row_group_indices.empty()) { return std::pair{}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 82228599cd4..7faae405b4a 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -323,6 +323,7 @@ class aggregate_reader_metadata { /** * @brief Filters the row groups based on predicate filter * + * @param sources Lists of input datasources. * @param row_group_indices Lists of row groups to read, one per source * @param output_dtypes Datatypes of of output columns * @param output_column_schemas schema indices of output columns @@ -331,6 +332,7 @@ class aggregate_reader_metadata { * @return Filtered row group indices, if any is filtered. */ [[nodiscard]] std::optional>> filter_row_groups( + host_span const> sources, host_span const> row_group_indices, host_span output_dtypes, host_span output_column_schemas, From f84825126e3eba2a3d3bd7ed7e11e341541687ed Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 05:04:13 +0000 Subject: [PATCH 05/82] Move contents to a separate file --- cpp/src/io/parquet/bloom_filter_reader.cpp | 209 +++++++++++++++++++++ cpp/src/io/parquet/predicate_pushdown.cpp | 12 -- cpp/src/io/parquet/reader_impl_helpers.cpp | 184 +----------------- cpp/src/io/parquet/reader_impl_helpers.hpp | 19 +- 4 files changed, 218 insertions(+), 206 deletions(-) create mode 100644 cpp/src/io/parquet/bloom_filter_reader.cpp diff --git a/cpp/src/io/parquet/bloom_filter_reader.cpp b/cpp/src/io/parquet/bloom_filter_reader.cpp new file mode 100644 index 00000000000..346ccd22c58 --- /dev/null +++ b/cpp/src/io/parquet/bloom_filter_reader.cpp @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "compact_protocol_reader.hpp" +#include "io/parquet/parquet.hpp" +#include "reader_impl_helpers.hpp" + +#include + +#include +#include + +#include + +#include +#include +#include + +namespace cudf::io::parquet::detail { + +namespace { +/** + * @brief Asynchronously reads bloom filters to device. + * + * @param sources Dataset sources + * @param num_chunks Number of total column chunks to read + * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param bloom_filter_offsets Bloom filter offsets for all chunks + * @param bloom_filter_sizes Bloom filter sizes for all chunks + * @param chunk_source_map Association between each column chunk and its source + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return A future object for reading synchronization + */ +std::future read_bloom_filters_async( + host_span const> sources, + size_t num_chunks, + cudf::host_span bloom_filter_data, + cudf::host_span> bloom_filter_offsets, + cudf::host_span> bloom_filter_sizes, + std::vector const& chunk_source_map, + rmm::cuda_stream_view stream) +{ + // Read tasks for bloom filter data + std::vector> read_tasks; + + // Read bloom filters for all column chunks + std::for_each( + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(num_chunks), + [&](auto const chunk) { + // Read bloom filter if present + if (bloom_filter_offsets[chunk].has_value()) { + auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); + // If Bloom filter size (header + bitset) is available, just read the entire thing. + // Else just read 256 bytes which will contain the entire header and may contain the + // entire bitset as well. + auto constexpr bloom_filter_header_size_guess = 256; + auto const initial_read_size = + bloom_filter_sizes[chunk].value_or(bloom_filter_header_size_guess); + + // Read an initial buffer from source + auto& source = sources[chunk_source_map[chunk]]; + auto buffer = source->host_read(bloom_filter_offset, initial_read_size); + + // Deserialize the Bloom filter header from the buffer. + BloomFilterHeader header; + CompactProtocolReader cp{buffer->data(), buffer->size()}; + cp.read(&header); + + // Test if header is valid. + auto const is_header_valid = + (header.num_bytes % 32) == 0 and + header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and + header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and + header.hash.hash == BloomFilterHash::Hash::XXHASH; + + // Do not read if the bloom filter is invalid + if (not is_header_valid) { + CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); + return; + } + + // Bloom filter header size + auto const bloom_filter_header_size = static_cast(cp.bytecount()); + auto const bitset_size = header.num_bytes; + + // Check if we already read in the filter bitset in the initial read. + if (initial_read_size >= bloom_filter_header_size + bitset_size) { + bloom_filter_data[chunk] = + rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); + } + // Read the bitset from datasource. + else { + auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; + // Directly read to device if preferred + if (source->is_device_read_preferred(bitset_size)) { + bloom_filter_data[chunk] = rmm::device_buffer(bitset_size, stream); + auto future_read_size = + source->device_read_async(bitset_offset, + bitset_size, + static_cast(bloom_filter_data[chunk].data()), + stream); + + read_tasks.emplace_back(std::move(future_read_size)); + } else { + buffer = source->host_read(bitset_offset, bitset_size); + bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); + } + } + } + }); + auto sync_fn = [](decltype(read_tasks) read_tasks) { + for (auto& task : read_tasks) { + task.wait(); + } + }; + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); +} + +} // namespace + +std::vector aggregate_reader_metadata::read_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span column_schemas, + rmm::cuda_stream_view stream) const +{ + // Number of total row groups to process. + auto const num_row_groups = std::accumulate( + row_group_indices.begin(), + row_group_indices.end(), + size_t{0}, + [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); + + // Descriptors for all the chunks that make up the selected columns + auto const num_input_columns = column_schemas.size(); + auto const num_chunks = num_row_groups * num_input_columns; + + // Association between each column chunk and its source + std::vector chunk_source_map(num_chunks); + + // Keep track of column chunk file offsets + std::vector> bloom_filter_offsets(num_chunks); + std::vector> bloom_filter_sizes(num_chunks); + + // Gather all bloom filter offsets and sizes. + size_type chunk_count = 0; + + // For all data sources + std::for_each(thrust::make_counting_iterator(0), + thrust::make_counting_iterator(row_group_indices.size()), + [&](auto const src_index) { + // Get all row group indices in the data source + auto const& rg_indices = row_group_indices[src_index]; + // For all row groups + std::for_each(rg_indices.cbegin(), rg_indices.cend(), [&](auto const rg_index) { + // For all column chunks + std::for_each( + column_schemas.begin(), column_schemas.end(), [&](auto const schema_idx) { + auto& col_meta = get_column_metadata(rg_index, src_index, schema_idx); + + // Get bloom filter offsets and sizes + bloom_filter_offsets[chunk_count] = col_meta.bloom_filter_offset; + bloom_filter_sizes[chunk_count] = col_meta.bloom_filter_length; + + // Map each column chunk to its source index + chunk_source_map[chunk_count] = src_index; + chunk_count++; + }); + }); + }); + + // Do we have any bloom filters + if (std::any_of(bloom_filter_offsets.cbegin(), + bloom_filter_offsets.cend(), + [](auto const offset) { return offset.has_value(); })) { + // Create a vector to store bloom filter data + std::vector bloom_filter_data(num_chunks); + + // Wait on bloom filter read tasks + read_bloom_filters_async(sources, + num_chunks, + bloom_filter_data, + bloom_filter_offsets, + bloom_filter_sizes, + chunk_source_map, + stream) + .wait(); + // Return the vector + return bloom_filter_data; + } + return {}; +} + +} // namespace cudf::io::parquet::detail \ No newline at end of file diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index abc8978d8db..a977cfdeca7 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -493,18 +493,6 @@ std::optional>> aggregate_reader_metadata::fi filtered_row_group_indices.push_back(std::move(filtered_row_groups)); } - // If equality predicate - if (true /* is_equality_predicate */) { - filtered_row_group_indices = - apply_bloom_filter_to_row_groups( - sources, - filtered_row_group_indices.empty() ? input_row_group_indices : filtered_row_group_indices, - output_dtypes, - output_column_schemas, - stream) - .value(); - } - return {std::move(filtered_row_group_indices)}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index 7ca1ffc4253..9c05009b528 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1033,188 +1033,6 @@ std::vector aggregate_reader_metadata::get_pandas_index_names() con return names; } -/** - * @brief Asynchronously reads bloom filters to device. - * - * @param sources Dataset sources - * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk - * @param chunks List of chunk indices to read - * @param bloom_filter_offsets Bloom filter offsets for all chunks - * @param bloom_filter_sizes Bloom filter sizes for all chunks - * @param chunk_source_map Association between each column chunk and its source - * @param stream CUDA stream used for device memory operations and kernel launches - * - * @return A future object for reading synchronization - */ -std::future read_bloom_filters_async( - host_span const> sources, - cudf::host_span bloom_filter_data, - host_span chunks, - cudf::host_span> bloom_filter_offsets, - cudf::host_span> bloom_filter_sizes, - std::vector const& chunk_source_map, - rmm::cuda_stream_view stream) -{ - // Transfer bloom filter data - std::vector> read_tasks; - - // FIXME: Do not read all chunks. Instead use a list of chunks to read. - for (auto chunk : chunks) { - // Read bloom filter if present - if (bloom_filter_offsets[chunk].has_value()) { - auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); - // If Bloom filter size (header + bitset) is available, just read the entire thing. - // Else just read 256 bytes which will contain the entire header and may contain the - // entire bitset as well. - auto constexpr bloom_filter_header_size_guess = 256; - auto const initial_read_size = - bloom_filter_sizes[chunk].value_or(bloom_filter_header_size_guess); - - // Read an initial buffer from source - auto& source = sources[chunk_source_map[chunk]]; - auto buffer = source->host_read(bloom_filter_offset, initial_read_size); - - // Deserialize the Bloom filter header from the buffer. - BloomFilterHeader header; - CompactProtocolReader cp{buffer->data(), buffer->size()}; - cp.read(&header); - - // Test if header is valid. - auto const is_header_valid = - (header.num_bytes % 32) == 0 and - header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and - header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and - header.hash.hash == BloomFilterHash::Hash::XXHASH; - - if (not is_header_valid) { - // Simply use an empty device buffer and move on. - bloom_filter_data[chunk] = rmm::device_buffer{}; - CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); - continue; - } - - // Bloom filter header size - auto const bloom_filter_header_size = static_cast(cp.bytecount()); - auto const bitset_size = header.num_bytes; - - // Check if we already read in the filter bitset in the initial read. - if (initial_read_size >= bloom_filter_header_size + bitset_size) { - bloom_filter_data[chunk] = - rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); - } - // Read the bitset from datasource. - else { - auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; - // Directly read to device if preferred - if (source->is_device_read_preferred(bitset_size)) { - bloom_filter_data[chunk] = rmm::device_buffer(bitset_size, stream); - auto future_read_size = - source->device_read_async(bitset_offset, - bitset_size, - static_cast(bloom_filter_data[chunk].data()), - stream); - - read_tasks.emplace_back(std::move(future_read_size)); - } else { - buffer = source->host_read(bitset_offset, bitset_size); - bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); - } - } - } else { - // Simply use an empty device buffer. - bloom_filter_data[chunk] = rmm::device_buffer{}; - } - } - auto sync_fn = [](decltype(read_tasks) read_tasks) { - for (auto& task : read_tasks) { - task.wait(); - } - }; - return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); -} - -std::optional>> -aggregate_reader_metadata::apply_bloom_filter_to_row_groups( - host_span const> sources, - host_span const> row_group_indices, - host_span output_dtypes, - host_span output_column_schemas, - rmm::cuda_stream_view stream) const -{ - // Create row group indices. - // std::vector> filtered_row_group_indices; - // Number of total row groups to process. - auto const num_row_groups = std::accumulate(row_group_indices.begin(), - row_group_indices.end(), - 0, - [](size_type sum, auto const& per_file_row_groups) { - return sum + per_file_row_groups.size(); - }); - - // Descriptors for all the chunks that make up the selected columns - auto const num_input_columns = output_dtypes.size(); - auto const num_chunks = num_row_groups * num_input_columns; - - // Association between each column chunk and its source - std::vector chunk_source_map(num_chunks); - - // Keep track of column chunk file offsets - std::vector> bloom_filter_offsets(num_chunks); - std::vector> bloom_filter_sizes(num_chunks); - - // List of chunks to read - std::vector chunks_to_read{}; - chunks_to_read.reserve(num_chunks); - - size_type chunk_count = 0; - - // FIXME: We don't need to read bloom filters for all chunks. Only the ones belonging to the - // columns of our interest (equality predicate). - // For all data sources - for (size_t rg_source_index = 0; rg_source_index < row_group_indices.size(); rg_source_index++) { - auto const& rg_index = row_group_indices[rg_source_index]; - // For all row groups in a data source - for (auto const rgi : rg_index) { - // For all column chunks in a row group - for (size_t col_idx = 0; col_idx < output_dtypes.size(); col_idx++) { - auto const schema_idx = output_column_schemas[col_idx]; - auto& col_meta = get_column_metadata(rgi, rg_source_index, schema_idx); - - // Bloom filter offsets and sizes - bloom_filter_offsets[chunk_count] = col_meta.bloom_filter_offset; - bloom_filter_sizes[chunk_count] = col_meta.bloom_filter_length; - chunks_to_read.emplace_back(chunk_count); - - // Map each column chunk to its column index and its source index - chunk_source_map[chunk_count] = rg_source_index; - chunk_count++; - } - } - } - - // Do we have any bloom filters - if (std::any_of(bloom_filter_offsets.cbegin(), - bloom_filter_offsets.cend(), - [](auto const offset) { return offset.has_value(); })) { - // Initialize the vector of bloom filter data buffers - std::vector bloom_filter_data(num_chunks); - - // Wait on bloom filter read tasks - read_bloom_filters_async(sources, - bloom_filter_data, - chunks_to_read, - bloom_filter_offsets, - bloom_filter_sizes, - chunk_source_map, - stream) - .wait(); - - // Apply bloom filter to row groups - // return apply_bloom_filters(); - } - return {}; -} - std::tuple, std::vector> aggregate_reader_metadata::select_row_groups( host_span const> sources, @@ -1230,7 +1048,7 @@ aggregate_reader_metadata::select_row_groups( // if filter is not empty, then gather row groups to read after predicate pushdown if (filter.has_value()) { filtered_row_group_indices = filter_row_groups( - row_group_indices, output_dtypes, output_column_schemas, filter.value(), stream); + sources, row_group_indices, output_dtypes, output_column_schemas, filter.value(), stream); if (filtered_row_group_indices.has_value()) { row_group_indices = host_span const>(filtered_row_group_indices.value()); diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 7faae405b4a..b03aa9ec296 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -367,24 +367,21 @@ class aggregate_reader_metadata { rmm::cuda_stream_view stream) const; /** - * @brief Filters the row groups using bloom filters + * @brief Reads bloom filter bitsets for the specified columns from the given lists of row + * groups. * * @param sources Dataset sources - * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk - * @param begin_chunk Index of first column chunk to read - * @param end_chunk Index after the last column chunk to read - * @param bloom_filter_offsets Bloom filter offsets for all chunks - * @param bloom_filter_sizes Bloom filter sizes for all chunks - * @param chunk_source_map Association between each column chunk and its source + * @param row_group_indices Lists of row groups to read bloom filters from, one per source + * @param column_schemas Schema indices of columns whose bloom filters will be read * @param stream CUDA stream used for device memory operations and kernel launches * - * @return Filtered row group indices, if any is filtered. + * @return A list of bloom filter bitset device buffers flattened over column schemas over lists + * of row group indices */ - std::optional>> apply_bloom_filter_to_row_groups( + std::vector read_bloom_filters( host_span const> sources, host_span const> row_group_indices, - host_span output_dtypes, - host_span output_column_schemas, + host_span column_schemas, rmm::cuda_stream_view stream) const; /** From 0b65233026b5868aa81ac86d51327d1e1521d625 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 05:06:19 +0000 Subject: [PATCH 06/82] Revert erroneous changes --- .../io/parquet/reader_apply_bloom_filters.cu | 36 ------------------- cpp/src/io/parquet/reader_impl_helpers.cpp | 5 --- 2 files changed, 41 deletions(-) delete mode 100644 cpp/src/io/parquet/reader_apply_bloom_filters.cu diff --git a/cpp/src/io/parquet/reader_apply_bloom_filters.cu b/cpp/src/io/parquet/reader_apply_bloom_filters.cu deleted file mode 100644 index fc85a0a1eb2..00000000000 --- a/cpp/src/io/parquet/reader_apply_bloom_filters.cu +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2024, NVIDIA CORPORATION. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @file bloom_filter_reader.cu - * @brief Bloom filter reader based row group filtration implementation - */ - -#include "parquet.hpp" -#include "parquet_common.hpp" - -#include - -#include - -#include -#include -#include -#include -#include - -// TODO: Implement this -cuda::std::optional>> apply_bloom_filters() { return {}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index 9c05009b528..ae720a9622d 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -25,16 +25,11 @@ #include -#include -#include - #include #include #include -#include #include -#include #include namespace cudf::io::parquet::detail { From cf7d762e26847842993dd4383f49c2fd0ca591a5 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 05:13:54 +0000 Subject: [PATCH 07/82] Style and doc fix --- cpp/src/io/parquet/bloom_filter_reader.cpp | 2 +- cpp/src/io/parquet/parquet.hpp | 29 +++++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cpp b/cpp/src/io/parquet/bloom_filter_reader.cpp index 346ccd22c58..fabc70cc5f2 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cpp +++ b/cpp/src/io/parquet/bloom_filter_reader.cpp @@ -206,4 +206,4 @@ std::vector aggregate_reader_metadata::read_bloom_filters( return {}; } -} // namespace cudf::io::parquet::detail \ No newline at end of file +} // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/parquet.hpp b/cpp/src/io/parquet/parquet.hpp index 118ec43133b..ff8e7a63486 100644 --- a/cpp/src/io/parquet/parquet.hpp +++ b/cpp/src/io/parquet/parquet.hpp @@ -396,40 +396,45 @@ struct ColumnChunkMetaData { }; /** - * @brief The algorithm used in Bloom filter. - **/ + * @brief The algorithm used in bloom filter + */ struct BloomFilterAlgorithm { - /** Block-based Bloom filter. **/ + // Block-based Bloom filter. enum Algorithm { UNDEFINED, SPLIT_BLOCK }; Algorithm algorithm{Algorithm::SPLIT_BLOCK}; }; /** - * @brief The hash function used in Bloom filter. This function takes the hash of a column value - * using plain encoding. - **/ + * @brief The hash function used in Bloom filter + */ struct BloomFilterHash { - /** xxHash Strategy. **/ + // xxHash_64 enum Hash { UNDEFINED, XXHASH }; Hash hash{Hash::XXHASH}; }; /** - * @brief The compression used in the Bloom filter. - **/ + * @brief The compression used in the bloom filter + */ struct BloomFilterCompression { enum Compression { UNDEFINED, UNCOMPRESSED }; Compression compression{Compression::UNCOMPRESSED}; }; +/** + * @brief Bloom filter header struct + * + * The bloom filter data of a column chunk stores this header at the beginning + * following by the filter bitset. + */ struct BloomFilterHeader { // The size of bitset in bytes int32_t num_bytes; - // The algorithm for setting bits. * + // The algorithm for setting bits BloomFilterAlgorithm algorithm; - // The hash function used for Bloom filter. * + // The hash function used for bloom filter BloomFilterHash hash; - // The compression used in the Bloom filter * + // The compression used in the bloom filter BloomFilterCompression compression; }; From 81efad2637e24f31b17b8a8d2ee25779446ddaf1 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 19 Nov 2024 03:58:42 +0000 Subject: [PATCH 08/82] Get equality predicate col indices --- ...lter_reader.cpp => bloom_filter_reader.cu} | 189 ++++++++++++++++++ cpp/src/io/parquet/predicate_pushdown.cpp | 8 +- cpp/src/io/parquet/reader_impl_helpers.hpp | 20 ++ 3 files changed, 216 insertions(+), 1 deletion(-) rename cpp/src/io/parquet/{bloom_filter_reader.cpp => bloom_filter_reader.cu} (51%) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cpp b/cpp/src/io/parquet/bloom_filter_reader.cu similarity index 51% rename from cpp/src/io/parquet/bloom_filter_reader.cpp rename to cpp/src/io/parquet/bloom_filter_reader.cu index fabc70cc5f2..89f448de05f 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cpp +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -18,14 +18,30 @@ #include "io/parquet/parquet.hpp" #include "reader_impl_helpers.hpp" +#include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include #include +#include #include +#include #include #include @@ -102,6 +118,37 @@ std::future read_bloom_filters_async( if (initial_read_size >= bloom_filter_header_size + bitset_size) { bloom_filter_data[chunk] = rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); + + /* MH: Remove this + using word_type = uint32_t; + + thrust::for_each(rmm::exec_policy(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(1), + [words = reinterpret_cast(bloom_filter_data[chunk].data()), + num_blocks = bloom_filter_data[chunk].size() / sizeof(uint32_t), + chunk = chunk, + stream = stream] __device__(auto idx) { + using key_type = cuda::std::string; + using policy_type = + cuco::bloom_filter_policy, std::uint32_t, + 8>; + // using arrow_policy_type = cuco::arrow_filter_policy; + cuco::bloom_filter_ref, + cuco::thread_scope_device, + policy_type> + filter{words, size_t{num_blocks}, {}, {8}}; + // cuda::std::string key{"third-136666"}; + // filter.add("third-136666"); + + cuco::xxhash_64 hasher{}; + cuda::std::array val{"third-136666"}; + auto hash = + hasher.compute_hash(reinterpret_cast(val.data()), val.size()); if + (filter.contains(hash)) { printf("Filter chunk: %lu contains key: third-136666\n", chunk); + } + });*/ } // Read the bitset from datasource. else { @@ -123,16 +170,158 @@ std::future read_bloom_filters_async( } } }); + auto sync_fn = [](decltype(read_tasks) read_tasks) { for (auto& task : read_tasks) { task.wait(); } }; + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); } +/** + * @brief Collects column indices with an equality predicate in the AST expression. + * This is used in row group filtering based on bloom filters. + */ +class equality_predicate_converter : public ast::detail::expression_transformer { + public: + equality_predicate_converter(ast::expression const& expr, size_type const& num_columns) + : _num_columns{num_columns} + { + expr.accept(*this); + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) + */ + std::reference_wrapper visit(ast::literal const& expr) override + { + _equality_expr = std::reference_wrapper(expr); + return expr; + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) + */ + std::reference_wrapper visit(ast::column_reference const& expr) override + { + CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, + "Equality AST supports only left table"); + CUDF_EXPECTS(expr.get_column_index() < _num_columns, + "Column index cannot be more than number of columns in the table"); + _equality_expr = std::reference_wrapper(expr); + return expr; + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) + */ + std::reference_wrapper visit( + ast::column_name_reference const& expr) override + { + CUDF_FAIL("Column name reference is not supported in equality AST"); + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + */ + std::reference_wrapper visit(ast::operation const& expr) override + { + using cudf::ast::ast_operator; + auto const operands = expr.get_operands(); + auto const op = expr.get_operator(); + + if (auto* v = dynamic_cast(&operands[0].get())) { + // First operand should be column reference, second should be literal. + CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, + "Only binary operations are supported on column reference"); + CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, + "Second operand of binary operation with column reference must be a literal"); + v->accept(*this); + if (op == ast_operator::EQUAL) { equality_col_idx.emplace_back(v->get_column_index()); } + } else { + auto new_operands = visit_operands(operands); + if (cudf::ast::detail::ast_operator_arity(op) == 2) { + _operators.emplace_back(op, new_operands.front(), new_operands.back()); + } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { + _operators.emplace_back(op, new_operands.front()); + } + } + _equality_expr = std::reference_wrapper(_operators.back()); + return std::reference_wrapper(_operators.back()); + } + + /** + * @brief Returns a list of column indices with an equality predicate in the AST expression. + * + * @return List of column indices + */ + [[nodiscard]] std::vector get_equality_col_idx() const + { + return equality_col_idx; + } + + private: + std::vector> visit_operands( + cudf::host_span const> operands) + { + std::vector> transformed_operands; + for (auto const& operand : operands) { + auto const new_operand = operand.get().accept(*this); + transformed_operands.push_back(new_operand); + } + return transformed_operands; + } + std::optional> _equality_expr; + std::vector equality_col_idx; + size_type _num_columns; + std::list _col_ref; + std::list _operators; +}; + } // namespace +std::optional>> aggregate_reader_metadata::apply_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + std::reference_wrapper filter, + rmm::cuda_stream_view stream) const +{ + auto const num_cols = output_dtypes.size(); + CUDF_EXPECTS(output_dtypes.size() == output_column_schemas.size(), + "Mismatched size between lists of output column dtypes and output column schema"); + auto mr = cudf::get_current_device_resource_ref(); + std::vector> cols; + // MH: How do I test for nested or non-comparable columns here? + cols.emplace_back(cudf::make_numeric_column( + data_type{cudf::type_id::INT32}, num_cols, rmm::device_buffer{}, 0, stream, mr)); + + auto mutable_col_idx = cols.back()->mutable_view(); + + thrust::sequence(rmm::exec_policy(stream), + mutable_col_idx.begin(), + mutable_col_idx.end(), + 0); + + auto equality_table = cudf::table(std::move(cols)); + + // Converts AST to EqualityAST with reference to min, max columns in above `stats_table`. + equality_predicate_converter equality_expr{filter.get(), static_cast(num_cols)}; + auto equality_col_schemas = equality_expr.get_equality_col_idx(); + + // Convert column indices to column schema indices + std::for_each(equality_col_schemas.begin(), equality_col_schemas.end(), [&](auto& col_idx) { + col_idx = output_column_schemas[col_idx]; + }); + + std::ignore = read_bloom_filters(sources, row_group_indices, equality_col_schemas, stream); + + return std::nullopt; +} + std::vector aggregate_reader_metadata::read_bloom_filters( host_span const> sources, host_span const> row_group_indices, diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index a977cfdeca7..eb30cc6955a 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -479,7 +479,9 @@ std::optional>> aggregate_reader_metadata::fi is_row_group_required.cend(), [](auto i) { return bool(i); }) or predicate.null_count() == predicate.size()) { - return std::nullopt; + // Call with input_row_group_indices + return apply_bloom_filters( + sources, input_row_group_indices, output_dtypes, output_column_schemas, filter, stream); } size_type is_required_idx = 0; for (auto const& input_row_group_index : input_row_group_indices) { @@ -493,6 +495,10 @@ std::optional>> aggregate_reader_metadata::fi filtered_row_group_indices.push_back(std::move(filtered_row_groups)); } + // Call with filtered_row_group_indices + return apply_bloom_filters( + sources, filtered_row_group_indices, output_dtypes, output_column_schemas, filter, stream); + return {std::move(filtered_row_group_indices)}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index b03aa9ec296..75edfdb7f7b 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -339,6 +339,26 @@ class aggregate_reader_metadata { std::reference_wrapper filter, rmm::cuda_stream_view stream) const; + /** + * @brief Filters the row groups using bloom filters + * + * @param sources Dataset sources + * @param row_group_indices Lists of row groups to read, one per source + * @param output_dtypes Datatypes of of output columns + * @param output_column_schemas schema indices of output columns + * @param filter AST expression to filter row groups based on Column chunk statistics + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return Filtered row group indices, if any is filtered. + */ + [[nodiscard]] std::optional>> apply_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + std::reference_wrapper filter, + rmm::cuda_stream_view stream) const; + /** * @brief Filters and reduces down to a selection of row groups * From 088377b713b35b3d516c0c2c05af9e1e9815a41f Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 20 Nov 2024 02:45:46 +0000 Subject: [PATCH 09/82] Enable `arrow_filter_policy` and `span` types in bloom filter. --- cpp/src/io/parquet/arrow_filter_policy.cuh | 187 +++++++++++++++++++++ cpp/src/io/parquet/bloom_filter_reader.cu | 89 ++++++---- 2 files changed, 246 insertions(+), 30 deletions(-) create mode 100644 cpp/src/io/parquet/arrow_filter_policy.cuh diff --git a/cpp/src/io/parquet/arrow_filter_policy.cuh b/cpp/src/io/parquet/arrow_filter_policy.cuh new file mode 100644 index 00000000000..95edf68cab4 --- /dev/null +++ b/cpp/src/io/parquet/arrow_filter_policy.cuh @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace cuco { + +/** + * @brief A policy that defines how Arrow Block-Split Bloom Filter generates and stores a key's + * fingerprint. + * + * @note: This file is a part of cuCollections. Copied here until we get a cuco bump for cudf. + * + * Reference: + * https://github.com/apache/arrow/blob/be1dcdb96b030639c0b56955c4c62f9d6b03f473/cpp/src/parquet/bloom_filter.cc#L219-L230 + * + * Example: + * @code{.cpp} + * template + * void bulk_insert_and_eval_arrow_policy_bloom_filter(device_vector const& positive_keys, + * device_vector const& negative_keys) + * { + * using policy_type = cuco::arrow_filter_policy; + * + * // Warn or throw if the number of filter blocks is greater than maximum used by Arrow policy. + * static_assert(NUM_FILTER_BLOCKS <= policy_type::max_filter_blocks, "NUM_FILTER_BLOCKS must be + * in range: [1, 4194304]"); + * + * // Create a bloom filter with Arrow policy + * cuco::bloom_filter, + * cuda::thread_scope_device, policy_type> filter{NUM_FILTER_BLOCKS}; + * + * // Add positive keys to the bloom filter + * filter.add(positive_keys.begin(), positive_keys.end()); + * + * auto const num_tp = positive_keys.size(); + * auto const num_tn = negative_keys.size(); + * + * // Vectors to store query results. + * thrust::device_vector true_positive_result(num_tp, false); + * thrust::device_vector true_negative_result(num_tn, false); + * + * // Query the bloom filter for the inserted keys. + * filter.contains(positive_keys.begin(), positive_keys.end(), true_positive_result.begin()); + * + * // We should see a true-positive rate of 1. + * float true_positive_rate = float(thrust::count(thrust::device, + * true_positive_result.begin(), true_positive_result.end(), true)) / float(num_tp); + * + * // Query the bloom filter for the non-inserted keys. + * filter.contains(negative_keys.begin(), negative_keys.end(), true_negative_result.begin()); + * + * // We may see a false-positive rate > 0 depending on the number of bits in the + * // filter and the number of hashes used per key. + * float false_positive_rate = float(thrust::count(thrust::device, + * true_negative_result.begin(), true_negative_result.end(), true)) / float(num_tn); + * } + * @endcode + * + * @tparam Key The type of the values to generate a fingerprint for. + */ +template +class arrow_filter_policy { + public: + using hasher = Hash; ///< Hash function for Arrow bloom filter policy + using word_type = std::uint32_t; ///< uint32_t for Arrow bloom filter policy + using hash_argument_type = typename hasher::argument_type; ///< Hash function input type + using hash_result_type = decltype(std::declval()( + std::declval())); ///< hash function output type + + static constexpr uint32_t bits_set_per_block = 8; ///< hardcoded bits set per Arrow filter block + static constexpr uint32_t words_per_block = 8; ///< hardcoded words per Arrow filter block + + static constexpr std::uint32_t bytes_per_filter_block = + 32; ///< Number of bytes in one Arrow filter block + static constexpr std::uint32_t max_arrow_filter_bytes = + 128 * 1024 * 1024; ///< Max bytes in Arrow bloom filter + static constexpr std::uint32_t max_filter_blocks = + (max_arrow_filter_bytes / + bytes_per_filter_block); ///< Max sub-filter blocks allowed in Arrow bloom filter + + private: + // Arrow's block-based bloom filter algorithm needs these eight odd SALT values to calculate + // eight indexes of bit to set, one bit in each 32-bit (uint32_t) word. + __device__ static constexpr cuda::std::array SALT() + { + return {0x47b6137bU, + 0x44974d91U, + 0x8824ad5bU, + 0xa2b7289dU, + 0x705495c7U, + 0x2df1424bU, + 0x9efc4947U, + 0x5c6bfb31U}; + } + + public: + /** + * @brief Constructs the `arrow_filter_policy` object. + * + * @note The number of filter blocks with Arrow policy must be in the + * range of [1, 4194304]. If the bloom filter is constructed with a larger + * number of blocks, only the first 4194304 (128MB) blocks will be used. + * + * @param hash Hash function used to generate a key's fingerprint + */ + __host__ __device__ constexpr arrow_filter_policy(hasher hash = {}) : hash_{hash} {} + + /** + * @brief Generates the hash value for a given key. + * + * @param key The key to hash + * + * @return The hash value of the key + */ + __device__ constexpr hash_result_type hash(hash_argument_type const& key) const + { + return hash_(key); + } + + /** + * @brief Determines the filter block a key is added into. + * + * @note The number of filter blocks with Arrow policy must be in the + * range of [1, 4194304]. Passing a larger `num_blocks` will still + * upperbound the number of blocks used to the mentioned range. + * + * @tparam Extent Size type that is used to determine the number of blocks in the filter + * + * @param hash Hash value of the key + * @param num_blocks Number of block in the filter + * + * @return The block index for the given key's hash value + */ + template + __device__ constexpr auto block_index(hash_result_type hash, Extent num_blocks) const + { + constexpr auto hash_bits = cuda::std::numeric_limits::digits; + // TODO: assert if num_blocks > max_filter_blocks + auto const max_blocks = cuda::std::min(num_blocks, max_filter_blocks); + // Make sure we are only contained withing the `max_filter_blocks` blocks + return static_cast(((hash >> hash_bits) * max_blocks) >> hash_bits) % max_blocks; + } + + /** + * @brief Determines the fingerprint pattern for a word/segment within the filter block for a + * given key's hash value. + * + * @param hash Hash value of the key + * @param word_index Target word/segment within the filter block + * + * @return The bit pattern for the word/segment in the filter block + */ + __device__ constexpr word_type word_pattern(hash_result_type hash, std::uint32_t word_index) const + { + // SALT array to calculate bit indexes for the current word + auto constexpr salt = SALT(); + word_type const key = static_cast(hash); + return word_type{1} << ((key * salt[word_index]) >> 27); + } + + private: + hasher hash_; +}; + +} // namespace cuco \ No newline at end of file diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 89f448de05f..5178fbba3c1 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -14,6 +14,7 @@ * limitations under the License. */ +#include "arrow_filter_policy.cuh" #include "compact_protocol_reader.hpp" #include "io/parquet/parquet.hpp" #include "reader_impl_helpers.hpp" @@ -36,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -48,6 +50,61 @@ namespace cudf::io::parquet::detail { namespace { + +/** + * @brief Temporary function that tests for key `third-037493` in bloom filters of column `c2` in + * test Parquet file. + * + * @param buffer Device buffer containing bloom filter bitset + * @param chunk Current row group index + * @param stream CUDA stream used for device memory operations and kernel launches + * + */ +void check_arbitrary_string_key(rmm::device_buffer const& buffer, + size_t chunk, + rmm::cuda_stream_view stream) +{ + using word_type = cuda::std::uint32_t; + using key_type = cuda::std::span; + using policy_type = cuco::arrow_filter_policy>; + + thrust::for_each( + rmm::exec_policy_nosync(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(1), + [bitset = const_cast(reinterpret_cast(buffer.data())), + num_blocks = static_cast(buffer.size()) / sizeof(uint32_t), + chunk = chunk, + stream = stream] __device__(auto idx) { + // using arrow_policy_type = cuco::arrow_filter_policy; + cuco::bloom_filter_ref, + cuco::thread_scope_device, + policy_type> + filter{ + bitset, + num_blocks, + {}, // scope + {0} // policy + }; + + // literal to search + cudf::string_view literal("third-037493", sizeof("third-037493")); + // convert to a cuda::std::span key to search + cuda::std::span const key( + const_cast(reinterpret_cast(literal.data())), + static_cast(literal.length())); + // Search in the filter + if (filter.contains(key)) { + printf("YES: Filter chunk: %lu contains key: third-037493\n", chunk); + } else { + printf("NO: Filter chunk: %lu does not contain key: third-037493\n", chunk); + } + }); + + stream.synchronize_no_throw(); +} + /** * @brief Asynchronously reads bloom filters to device. * @@ -119,36 +176,8 @@ std::future read_bloom_filters_async( bloom_filter_data[chunk] = rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); - /* MH: Remove this - using word_type = uint32_t; - - thrust::for_each(rmm::exec_policy(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(1), - [words = reinterpret_cast(bloom_filter_data[chunk].data()), - num_blocks = bloom_filter_data[chunk].size() / sizeof(uint32_t), - chunk = chunk, - stream = stream] __device__(auto idx) { - using key_type = cuda::std::string; - using policy_type = - cuco::bloom_filter_policy, std::uint32_t, - 8>; - // using arrow_policy_type = cuco::arrow_filter_policy; - cuco::bloom_filter_ref, - cuco::thread_scope_device, - policy_type> - filter{words, size_t{num_blocks}, {}, {8}}; - // cuda::std::string key{"third-136666"}; - // filter.add("third-136666"); - - cuco::xxhash_64 hasher{}; - cuda::std::array val{"third-136666"}; - auto hash = - hasher.compute_hash(reinterpret_cast(val.data()), val.size()); if - (filter.contains(hash)) { printf("Filter chunk: %lu contains key: third-136666\n", chunk); - } - });*/ + // MH: TODO: Temporary test. Remove me!! + check_arbitrary_string_key(bloom_filter_data[chunk], chunk, stream); } // Read the bitset from datasource. else { From 3dff590adeb7255d93e7fd15e081c25e5401d0ca Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 21 Nov 2024 08:41:10 +0000 Subject: [PATCH 10/82] Successfully search bloom filter --- cpp/src/io/parquet/bloom_filter_reader.cu | 148 ++++++++++++++++++---- 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 5178fbba3c1..3c0c906a448 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -22,22 +22,18 @@ #include #include #include -#include #include -#include #include #include -#include +#include #include #include #include #include -#include +#include #include -#include -#include #include #include #include @@ -46,11 +42,115 @@ #include #include #include +#include namespace cudf::io::parquet::detail { namespace { +std::pair, std::vector> generate_chars_and_offsets(size_t num_keys) +{ + static std::vector const strings{"first", + "second", + "third", + "fourth", + "fifth", + "sixth", + "seventh", + "eighth", + "ninth", + "tenth", + "eleventh", + "twelfth", + "thirteenth", + "fourteenth", + "fifteenth", + "sixteenth", + "seventeenth", + "eighteenth"}; + + auto constexpr seed = 0xf00d; + /*static*/ std::mt19937 engine{seed}; + /*static*/ std::uniform_int_distribution dist{}; + + std::vector offsets(num_keys + 1); + std::vector chars; + chars.reserve(12 * num_keys); // 12 is the length of "seventeenth", the largest string + size_t offset = 0; + offsets[0] = size_t{0}; + std::generate_n(offsets.begin() + 1, num_keys, [&]() { + auto const& string = strings[dist(engine) % strings.size()]; + auto const char_ptr = const_cast(string.data()); + chars.insert(chars.end(), char_ptr, char_ptr + string.length()); + offset += string.length(); + return offset; + }); + return {std::move(chars), std::move(offsets)}; +} + +void validate_filter_bitwise(rmm::cuda_stream_view stream) +{ + std::size_t constexpr num_filter_blocks = 4; + std::size_t constexpr num_keys = 50; + + // Generate strings data + auto const [h_chars, h_offsets] = generate_chars_and_offsets(num_keys); + auto const mr = cudf::get_current_device_resource_ref(); + auto d_chars = cudf::detail::make_device_uvector_async(h_chars, stream, mr); + auto d_offsets = cudf::detail::make_device_uvector_async(h_offsets, stream, mr); + + rmm::device_uvector d_keys(num_keys, stream); + thrust::transform(rmm::exec_policy_nosync(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(num_keys), + d_keys.begin(), + [chars = thrust::raw_pointer_cast(d_chars.data()), + offsets = thrust::raw_pointer_cast(d_offsets.data())] __device__(auto idx) { + return cudf::string_view{ + chars + offsets[idx], + static_cast(offsets[idx + 1] - offsets[idx])}; + }); + + using key_type = cudf::string_view; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + using filter_type = cuco::bloom_filter, + cuda::thread_scope_device, + policy_type, + cudf::detail::cuco_allocator>; + // Spawn a bloom filter + filter_type filter{ + num_filter_blocks, + cuco::thread_scope_device, + {hasher_type{0}}, + cudf::detail::cuco_allocator{rmm::mr::polymorphic_allocator{}, stream}, + stream}; + + // Add strings to the bloom filter + filter.add(d_keys.begin(), d_keys.end(), stream); + + using word_type = filter_type::word_type; + + // Number of words in the filter + size_t const num_words = filter.block_extent() * filter.words_per_block; + + // Get the filter bitset words + cudf::device_span filter_bitset(filter.data(), num_words); + // Expected filter bitset words + rmm::device_vector const expected_bitset{ + 4194306, 4194305, 2359296, 1073774592, 524544, 1024, 268443648, 8519680, + 2147500040, 8421380, 269500416, 4202624, 8396802, 100665344, 2147747840, 5243136, + 131146, 655364, 285345792, 134222340, 545390596, 2281717768, 51201, 41943553, + 1619656708, 67441680, 8462730, 361220, 2216738864, 587333888, 4219272, 873463873}; + + CUDF_EXPECTS(thrust::equal(rmm::exec_policy(stream), + expected_bitset.cbegin(), + expected_bitset.cend(), + filter_bitset.begin()), + "Bloom filter bitset mismatched"); +} + /** * @brief Temporary function that tests for key `third-037493` in bloom filters of column `c2` in * test Parquet file. @@ -64,16 +164,21 @@ void check_arbitrary_string_key(rmm::device_buffer const& buffer, size_t chunk, rmm::cuda_stream_view stream) { - using word_type = cuda::std::uint32_t; - using key_type = cuda::std::span; - using policy_type = cuco::arrow_filter_policy>; + using key_type = cudf::string_view; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + using word_type = policy_type::word_type; + + auto constexpr word_size = sizeof(word_type); + auto constexpr words_per_block = policy_type::words_per_block; + auto const num_filter_blocks = buffer.size() / (word_size * words_per_block); thrust::for_each( - rmm::exec_policy_nosync(stream), + rmm::exec_policy(stream), thrust::make_counting_iterator(0), thrust::make_counting_iterator(1), [bitset = const_cast(reinterpret_cast(buffer.data())), - num_blocks = static_cast(buffer.size()) / sizeof(uint32_t), + num_blocks = num_filter_blocks, chunk = chunk, stream = stream] __device__(auto idx) { // using arrow_policy_type = cuco::arrow_filter_policy; @@ -81,19 +186,13 @@ void check_arbitrary_string_key(rmm::device_buffer const& buffer, cuco::extent, cuco::thread_scope_device, policy_type> - filter{ - bitset, - num_blocks, - {}, // scope - {0} // policy - }; + filter{bitset, + num_blocks, + {}, // scope + {hasher_type{0}}}; // literal to search - cudf::string_view literal("third-037493", sizeof("third-037493")); - // convert to a cuda::std::span key to search - cuda::std::span const key( - const_cast(reinterpret_cast(literal.data())), - static_cast(literal.length())); + cudf::string_view key("third-037493", 12); // Search in the filter if (filter.contains(key)) { printf("YES: Filter chunk: %lu contains key: third-037493\n", chunk); @@ -101,8 +200,6 @@ void check_arbitrary_string_key(rmm::device_buffer const& buffer, printf("NO: Filter chunk: %lu does not contain key: third-037493\n", chunk); } }); - - stream.synchronize_no_throw(); } /** @@ -206,6 +303,9 @@ std::future read_bloom_filters_async( } }; + // MH: Remove me. Bitwise validation for bloom filter + validate_filter_bitwise(stream); + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); } From 71e1d331f79ddef0521489f142527239836f6433 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 21 Nov 2024 19:03:22 +0000 Subject: [PATCH 11/82] style fix --- cpp/src/io/parquet/arrow_filter_policy.cuh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/arrow_filter_policy.cuh b/cpp/src/io/parquet/arrow_filter_policy.cuh index 95edf68cab4..dfb6745bed8 100644 --- a/cpp/src/io/parquet/arrow_filter_policy.cuh +++ b/cpp/src/io/parquet/arrow_filter_policy.cuh @@ -184,4 +184,4 @@ class arrow_filter_policy { hasher hash_; }; -} // namespace cuco \ No newline at end of file +} // namespace cuco From aa65a2bb58238c27a9f6d16fe714fe9e3c59dfd6 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Fri, 22 Nov 2024 02:35:00 +0000 Subject: [PATCH 12/82] Code cleanup --- cpp/src/io/parquet/bloom_filter_reader.cu | 80 +++++++++++++---------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 3c0c906a448..6eed5c5dd21 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -34,8 +34,8 @@ #include #include -#include #include +#include #include #include @@ -155,51 +155,61 @@ void validate_filter_bitwise(rmm::cuda_stream_view stream) * @brief Temporary function that tests for key `third-037493` in bloom filters of column `c2` in * test Parquet file. * + * @tparam Key Type of the key to query bloom filter. + * * @param buffer Device buffer containing bloom filter bitset * @param chunk Current row group index * @param stream CUDA stream used for device memory operations and kernel launches * */ -void check_arbitrary_string_key(rmm::device_buffer const& buffer, - size_t chunk, - rmm::cuda_stream_view stream) +template +[[nodiscard]] bool check_arbitrary_string_key(rmm::device_buffer const& buffer, + Key const& literal, + size_t chunk_idx, + rmm::cuda_stream_view stream) { - using key_type = cudf::string_view; + using key_type = Key; using hasher_type = cudf::hashing::detail::XXHash_64; using policy_type = cuco::arrow_filter_policy; - using word_type = policy_type::word_type; + using word_type = typename policy_type::word_type; + // Filter properties auto constexpr word_size = sizeof(word_type); auto constexpr words_per_block = policy_type::words_per_block; auto const num_filter_blocks = buffer.size() / (word_size * words_per_block); - thrust::for_each( - rmm::exec_policy(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(1), - [bitset = const_cast(reinterpret_cast(buffer.data())), - num_blocks = num_filter_blocks, - chunk = chunk, - stream = stream] __device__(auto idx) { - // using arrow_policy_type = cuco::arrow_filter_policy; - cuco::bloom_filter_ref, - cuco::thread_scope_device, - policy_type> - filter{bitset, - num_blocks, - {}, // scope - {hasher_type{0}}}; - - // literal to search - cudf::string_view key("third-037493", 12); - // Search in the filter - if (filter.contains(key)) { - printf("YES: Filter chunk: %lu contains key: third-037493\n", chunk); - } else { - printf("NO: Filter chunk: %lu does not contain key: third-037493\n", chunk); - } - }); + // Bitset ptr must be a non-const to be used in bloom_filter_ref. + auto bitset = const_cast(reinterpret_cast(buffer.data())); + + // Create a bloom filter view + cuco:: + bloom_filter_ref, cuco::thread_scope_device, policy_type> + filter{bitset, + num_filter_blocks, + {}, // scope + {hasher_type{0}}}; + + // Copy over the key (literal) to device + rmm::device_buffer d_key{literal.data(), static_cast(literal.size_bytes()), stream}; + + // Query literal in bloom filter. + bool const is_literal_found = + thrust::count_if(rmm::exec_policy(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(1), + [filter = filter, + key_ptr = reinterpret_cast(d_key.data()), + key_size = static_cast(d_key.size())] __device__(auto idx) { + auto const d_key = cudf::string_view{key_ptr, key_size}; + return filter.contains(d_key); + }); + + // MH: Temporary + auto const found_string = (is_literal_found) ? "YES" : "NO"; + std::cout << "Literal: third-037493 found in chunk " << chunk_idx << ": " << found_string + << std::endl; + + return is_literal_found; } /** @@ -274,7 +284,9 @@ std::future read_bloom_filters_async( rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); // MH: TODO: Temporary test. Remove me!! - check_arbitrary_string_key(bloom_filter_data[chunk], chunk, stream); + cudf::string_view literal{"third-037493", 12}; + std::ignore = + check_arbitrary_string_key(bloom_filter_data[chunk], literal, chunk, stream); } // Read the bitset from datasource. else { From c52821b91db31380c4cfc570bb08ba0d1093ef57 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Mon, 25 Nov 2024 21:30:54 +0000 Subject: [PATCH 13/82] add tests --- cpp/tests/CMakeLists.txt | 1 + cpp/tests/hashing/bloom_filter_test.cu | 95 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 cpp/tests/hashing/bloom_filter_test.cu diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 666a7d4ba4b..219fafc6952 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -182,6 +182,7 @@ ConfigureTest(DATETIME_OPS_TEST datetime/datetime_ops_test.cpp) # * hashing tests --------------------------------------------------------------------------------- ConfigureTest( HASHING_TEST + hashing/bloom_filter_test.cu hashing/md5_test.cpp hashing/murmurhash3_x86_32_test.cpp hashing/murmurhash3_x64_128_test.cpp diff --git a/cpp/tests/hashing/bloom_filter_test.cu b/cpp/tests/hashing/bloom_filter_test.cu new file mode 100644 index 00000000000..e0e98dd84a0 --- /dev/null +++ b/cpp/tests/hashing/bloom_filter_test.cu @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* +#include +#include +#include +#include + +#include +#include +#include + +#include + +using StringType = cudf::string_view; + +template +class BloomFilter_TestTyped : public cudf::test::BaseFixture {}; + +TYPED_TEST_SUITE(BloomFilter_TestTyped, StringType); + +TYPED_TEST(BloomFilter_TestTyped, TestStrings) +{ + using key_type = StringType; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + + std::size_t constexpr num_filter_blocks = 4; + std::size_t constexpr num_keys = 50; + auto stream = cudf::get_default_stream(); + + // strings data + auto data = cudf::test::strings_column_wrapper( + {"seventh", "fifteenth", "second", "tenth", "fifth", "first", + "seventh", "tenth", "ninth", "ninth", "seventeenth", "eighteenth", + "thirteenth", "fifth", "fourth", "twelfth", "second", "second", + "fourth", "seventh", "seventh", "tenth", "thirteenth", "seventeenth", + "fifth", "seventeenth", "eighth", "fourth", "second", "eighteenth", + "fifteenth", "second", "seventeenth", "thirteenth", "eighteenth", "fifth", + "seventh", "tenth", "fourteenth", "first", "fifth", "fifth", + "tenth", "thirteenth", "fourteenth", "third", "third", "sixth", + "first", "third"}); + auto d_column = cudf::column_device_view::create(data); + + // Spawn a bloom filter + cuco::bloom_filter, + cuda::thread_scope_device, + policy_type, + cudf::detail::cuco_allocator> + filter{num_filter_blocks, + cuco::thread_scope_device, + {hasher_type{0}}, + cudf::detail::cuco_allocator{rmm::mr::polymorphic_allocator{}, stream}, + stream}; + + // Add strings to the bloom filter + auto col = table.column(0); + filter.add(d_column->begin(), d_column->end(), stream); + + // Number of words in the filter + cudf::size_type const num_words = filter.block_extent() * filter.words_per_block; + + auto const output = cudf::column_view{ + cudf::data_type{cudf::type_id::UINT32}, num_words, filter.data(), nullptr, 0, 0, {}}; + + using word_type = filter_type::word_type; + + // Expected filter bitset words computed using Arrow implementation here: + // https://godbolt.org/z/oKfqcPWbY + auto expected = cudf::test::fixed_width_column_wrapper( + {4194306, 4194305, 2359296, 1073774592, 524544, 1024, 268443648, 8519680, + 2147500040, 8421380, 269500416, 4202624, 8396802, 100665344, 2147747840, 5243136, + 131146, 655364, 285345792, 134222340, 545390596, 2281717768, 51201, 41943553, + 1619656708, 67441680, 8462730, 361220, 2216738864, 587333888, 4219272, 873463873}); + auto d_expected = cudf::column_device_view::create(expected); + + // Check + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output, d_expected); +} +*/ \ No newline at end of file From 3a20a9814f60c08b893e9eb917d69f31672779c4 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 9 Nov 2024 00:11:28 +0000 Subject: [PATCH 14/82] Initial stuff for reading bloom filter from PQ files --- .../io/parquet/compact_protocol_reader.cpp | 33 +++- .../io/parquet/compact_protocol_reader.hpp | 4 + cpp/src/io/parquet/parquet.hpp | 45 +++++ .../io/parquet/reader_apply_bloom_filters.cu | 36 ++++ cpp/src/io/parquet/reader_impl_helpers.cpp | 178 ++++++++++++++++++ cpp/src/io/parquet/reader_impl_helpers.hpp | 25 ++- cpp/src/io/parquet/reader_impl_preprocess.cu | 3 +- 7 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 cpp/src/io/parquet/reader_apply_bloom_filters.cu diff --git a/cpp/src/io/parquet/compact_protocol_reader.cpp b/cpp/src/io/parquet/compact_protocol_reader.cpp index d276e946a51..55874822ade 100644 --- a/cpp/src/io/parquet/compact_protocol_reader.cpp +++ b/cpp/src/io/parquet/compact_protocol_reader.cpp @@ -655,6 +655,33 @@ void CompactProtocolReader::read(ColumnChunk* c) function_builder(this, op); } +void CompactProtocolReader::read(BloomFilterAlgorithm* alg) +{ + auto op = std::make_tuple(parquet_field_union_enumerator(1, alg->algorithm)); + function_builder(this, op); +} + +void CompactProtocolReader::read(BloomFilterHash* hash) +{ + auto op = std::make_tuple(parquet_field_union_enumerator(1, hash->hash)); + function_builder(this, op); +} + +void CompactProtocolReader::read(BloomFilterCompression* comp) +{ + auto op = std::make_tuple(parquet_field_union_enumerator(1, comp->compression)); + function_builder(this, op); +} + +void CompactProtocolReader::read(BloomFilterHeader* bf) +{ + auto op = std::make_tuple(parquet_field_int32(1, bf->num_bytes), + parquet_field_struct(2, bf->algorithm), + parquet_field_struct(3, bf->hash), + parquet_field_struct(4, bf->compression)); + function_builder(this, op); +} + void CompactProtocolReader::read(ColumnChunkMetaData* c) { using optional_size_statistics = @@ -662,7 +689,9 @@ void CompactProtocolReader::read(ColumnChunkMetaData* c) using optional_list_enc_stats = parquet_field_optional, parquet_field_struct_list>; - auto op = std::make_tuple(parquet_field_enum(1, c->type), + using optional_i64 = parquet_field_optional; + using optional_i32 = parquet_field_optional; + auto op = std::make_tuple(parquet_field_enum(1, c->type), parquet_field_enum_list(2, c->encodings), parquet_field_string_list(3, c->path_in_schema), parquet_field_enum(4, c->codec), @@ -674,6 +703,8 @@ void CompactProtocolReader::read(ColumnChunkMetaData* c) parquet_field_int64(11, c->dictionary_page_offset), parquet_field_struct(12, c->statistics), optional_list_enc_stats(13, c->encoding_stats), + optional_i64(14, c->bloom_filter_offset), + optional_i32(15, c->bloom_filter_length), optional_size_statistics(16, c->size_statistics)); function_builder(this, op); } diff --git a/cpp/src/io/parquet/compact_protocol_reader.hpp b/cpp/src/io/parquet/compact_protocol_reader.hpp index b87f2e9c692..3d39daa60d1 100644 --- a/cpp/src/io/parquet/compact_protocol_reader.hpp +++ b/cpp/src/io/parquet/compact_protocol_reader.hpp @@ -108,6 +108,10 @@ class CompactProtocolReader { void read(IntType* t); void read(RowGroup* r); void read(ColumnChunk* c); + void read(BloomFilterAlgorithm* bf); + void read(BloomFilterHash* bf); + void read(BloomFilterCompression* bf); + void read(BloomFilterHeader* bf); void read(ColumnChunkMetaData* c); void read(PageHeader* p); void read(DataPageHeader* d); diff --git a/cpp/src/io/parquet/parquet.hpp b/cpp/src/io/parquet/parquet.hpp index 2851ef67a65..118ec43133b 100644 --- a/cpp/src/io/parquet/parquet.hpp +++ b/cpp/src/io/parquet/parquet.hpp @@ -382,12 +382,57 @@ struct ColumnChunkMetaData { // Set of all encodings used for pages in this column chunk. This information can be used to // determine if all data pages are dictionary encoded for example. std::optional> encoding_stats; + // Byte offset from beginning of file to Bloom filter data. + std::optional bloom_filter_offset; + // Size of Bloom filter data including the serialized header, in bytes. Added in 2.10 so readers + // may not read this field from old files and it can be obtained after the BloomFilterHeader has + // been deserialized. Writers should write this field so readers can read the bloom filter in a + // single I/O. + std::optional bloom_filter_length; // Optional statistics to help estimate total memory when converted to in-memory representations. // The histograms contained in these statistics can also be useful in some cases for more // fine-grained nullability/list length filter pushdown. std::optional size_statistics; }; +/** + * @brief The algorithm used in Bloom filter. + **/ +struct BloomFilterAlgorithm { + /** Block-based Bloom filter. **/ + enum Algorithm { UNDEFINED, SPLIT_BLOCK }; + Algorithm algorithm{Algorithm::SPLIT_BLOCK}; +}; + +/** + * @brief The hash function used in Bloom filter. This function takes the hash of a column value + * using plain encoding. + **/ +struct BloomFilterHash { + /** xxHash Strategy. **/ + enum Hash { UNDEFINED, XXHASH }; + Hash hash{Hash::XXHASH}; +}; + +/** + * @brief The compression used in the Bloom filter. + **/ +struct BloomFilterCompression { + enum Compression { UNDEFINED, UNCOMPRESSED }; + Compression compression{Compression::UNCOMPRESSED}; +}; + +struct BloomFilterHeader { + // The size of bitset in bytes + int32_t num_bytes; + // The algorithm for setting bits. * + BloomFilterAlgorithm algorithm; + // The hash function used for Bloom filter. * + BloomFilterHash hash; + // The compression used in the Bloom filter * + BloomFilterCompression compression; +}; + /** * @brief Thrift-derived struct describing a chunk of data for a particular * column diff --git a/cpp/src/io/parquet/reader_apply_bloom_filters.cu b/cpp/src/io/parquet/reader_apply_bloom_filters.cu new file mode 100644 index 00000000000..2d1c20ba48c --- /dev/null +++ b/cpp/src/io/parquet/reader_apply_bloom_filters.cu @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file bloom_filter_reader.cu + * @brief Bloom filter reader based row group filtration implementation + */ + +#include "parquet.hpp" +#include "parquet_common.hpp" + +#include + +#include + +#include +#include +#include +#include +#include + +// TODO: Implement this +cuda::std::optional>> apply_bloom_filters() { return {}; } \ No newline at end of file diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index a6562d33de2..b6348a3f897 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -25,11 +25,16 @@ #include +#include +#include + #include #include #include +#include #include +#include #include namespace cudf::io::parquet::detail { @@ -1028,8 +1033,169 @@ std::vector aggregate_reader_metadata::get_pandas_index_names() con return names; } +/** + * @brief Asynchronously reads bloom filters to device. + * + * @param sources Dataset sources + * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param chunks List of chunk indices to read + * @param bloom_filter_offsets Bloom filter offsets for all chunks + * @param bloom_filter_sizes Bloom filter sizes for all chunks + * @param chunk_source_map Association between each column chunk and its source + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return A future object for reading synchronization + */ +std::future read_bloom_filters_async( + host_span const> sources, + cudf::host_span bloom_filter_data, + host_span chunks, + cudf::host_span> bloom_filter_offsets, + cudf::host_span> bloom_filter_sizes, + std::vector const& chunk_source_map, + rmm::cuda_stream_view stream) +{ + // Transfer bloom filter data + std::vector> read_tasks; + + // FIXME: Do not read all chunks. Instead use a list of chunks to read. + for (auto chunk : chunks) { + // Read bloom filter if present + if (bloom_filter_offsets[chunk].has_value()) { + auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); + // If Bloom filter size (header + bitset) is available, just read the entire thing. + // Else just read 256 bytes which will contain the entire header and may contain the + // entire bitset as well. + auto constexpr bloom_filter_header_size_guess = 256; + auto const initial_read_size = + bloom_filter_sizes[chunk].value_or(bloom_filter_header_size_guess); + + // Read an initial buffer from source + auto& source = sources[chunk_source_map[chunk]]; + auto buffer = source->host_read(bloom_filter_offset, initial_read_size); + + // Deserialize the Bloom filter header from the buffer. + BloomFilterHeader header; + CompactProtocolReader cp{buffer->data(), buffer->size()}; + cp.read(&header); + + // Bloom filter header size + auto const bloom_filter_header_size = static_cast(cp.bytecount()); + auto const bitset_size = header.num_bytes; + + // Check if we already read in the filter bitset in the initial read. + if (initial_read_size >= bloom_filter_header_size + bitset_size) { + bloom_filter_data[chunk] = + rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); + } + // Read the bitset from datasource. + else { + auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; + // Directly read to device if preferred + if (source->is_device_read_preferred(bitset_size)) { + bloom_filter_data[chunk] = rmm::device_buffer(bitset_size, stream); + auto future_read_size = + source->device_read_async(bitset_offset, + bitset_size, + static_cast(bloom_filter_data[chunk].data()), + stream); + + read_tasks.emplace_back(std::move(future_read_size)); + } else { + buffer = source->host_read(bitset_offset, bitset_size); + bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); + } + } + } + } + auto sync_fn = [](decltype(read_tasks) read_tasks) { + for (auto& task : read_tasks) { + task.wait(); + } + }; + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); +} + +std::optional>> +aggregate_reader_metadata::apply_bloom_filter_to_row_groups( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + rmm::cuda_stream_view stream) const +{ + // Number of row groups after filter_row_groups(). + auto const num_row_groups = std::accumulate( + row_group_indices.begin(), row_group_indices.end(), 0, [](auto& sum, auto const& rgis) { + return sum + rgis.size(); + }); + // Descriptors for all the chunks that make up the selected columns + auto const num_input_columns = output_dtypes.size(); + auto const num_chunks = num_row_groups * num_input_columns; + + // Association between each column chunk and its source + std::vector chunk_source_map(num_chunks); + + // Keep track of column chunk file offsets + std::vector> bloom_filter_offsets(num_chunks); + std::vector> bloom_filter_sizes(num_chunks); + + // List of chunks to read + std::vector chunks_to_read{}; + chunks_to_read.reserve(num_chunks); + + size_type chunk_count = 0; + + // FIXME: We don't need to read bloom filters for all chunks. Only the ones belonging to the + // columns of our interest (equality predicate). + // For all data sources + for (size_t rg_source_index = 0; rg_source_index < row_group_indices.size(); rg_source_index++) { + auto const& rg_index = row_group_indices[rg_source_index]; + // For all row groups in a data source + for (auto const rgi : rg_index) { + // For all column chunks in a row group + for (size_t col_idx = 0; col_idx < output_dtypes.size(); col_idx++) { + auto const schema_idx = output_column_schemas[col_idx]; + auto& col_meta = get_column_metadata(rgi, rg_source_index, schema_idx); + + // Bloom filter offsets and sizes + bloom_filter_offsets[chunk_count] = col_meta.bloom_filter_offset; + bloom_filter_sizes[chunk_count] = col_meta.bloom_filter_length; + chunks_to_read.emplace_back(chunk_count); + + // Map each column chunk to its column index and its source index + chunk_source_map[chunk_count] = rg_source_index; + chunk_count++; + } + } + } + + // Do we have any bloom filters + if (std::any_of(bloom_filter_offsets.cbegin(), + bloom_filter_offsets.cend(), + [](auto const offset) { return offset.has_value(); })) { + // Initialize the vector of bloom filter data buffers + std::vector bloom_filter_data(num_chunks); + + // Wait on bloom filter read tasks + read_bloom_filters_async(sources, + bloom_filter_data, + chunks_to_read, + bloom_filter_offsets, + bloom_filter_sizes, + chunk_source_map, + stream) + .wait(); + + // Apply bloom filter to row groups + // return apply_bloom_filters(); + } + return {}; +} + std::tuple, std::vector> aggregate_reader_metadata::select_row_groups( + host_span const> sources, host_span const> row_group_indices, int64_t skip_rows_opt, std::optional const& num_rows_opt, @@ -1048,6 +1214,18 @@ aggregate_reader_metadata::select_row_groups( host_span const>(filtered_row_group_indices.value()); } } + + // FIXME: Provide the actual condition for this if + if (true /* equality predicate provided */) { + filtered_row_group_indices = apply_bloom_filter_to_row_groups( + sources, row_group_indices, output_dtypes, output_column_schemas, stream); + + if (filtered_row_group_indices.has_value()) { + row_group_indices = + host_span const>(filtered_row_group_indices.value()); + } + } + std::vector selection; auto [rows_to_skip, rows_to_read] = [&]() { if (not row_group_indices.empty()) { return std::pair{}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index fd692c0cdd6..82228599cd4 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -343,6 +343,7 @@ class aggregate_reader_metadata { * The input `row_start` and `row_count` parameters will be recomputed and output as the valid * values based on the input row group list. * + * @param sources Lists of input datasources. * @param row_group_indices Lists of row groups to read, one per source * @param row_start Starting row of the selection * @param row_count Total number of rows selected @@ -354,7 +355,8 @@ class aggregate_reader_metadata { * starting row, and list of number of rows per source. */ [[nodiscard]] std::tuple, std::vector> - select_row_groups(host_span const> row_group_indices, + select_row_groups(host_span const> sources, + host_span const> row_group_indices, int64_t row_start, std::optional const& row_count, host_span output_dtypes, @@ -362,6 +364,27 @@ class aggregate_reader_metadata { std::optional> filter, rmm::cuda_stream_view stream) const; + /** + * @brief Filters the row groups using bloom filters + * + * @param sources Dataset sources + * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param begin_chunk Index of first column chunk to read + * @param end_chunk Index after the last column chunk to read + * @param bloom_filter_offsets Bloom filter offsets for all chunks + * @param bloom_filter_sizes Bloom filter sizes for all chunks + * @param chunk_source_map Association between each column chunk and its source + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return Filtered row group indices, if any is filtered. + */ + std::optional>> apply_bloom_filter_to_row_groups( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + rmm::cuda_stream_view stream) const; + /** * @brief Filters and reduces down to a selection of columns * diff --git a/cpp/src/io/parquet/reader_impl_preprocess.cu b/cpp/src/io/parquet/reader_impl_preprocess.cu index bcdae4cbd3b..135ca56af89 100644 --- a/cpp/src/io/parquet/reader_impl_preprocess.cu +++ b/cpp/src/io/parquet/reader_impl_preprocess.cu @@ -1284,7 +1284,8 @@ void reader::impl::preprocess_file(read_mode mode) _file_itm_data.global_num_rows, _file_itm_data.row_groups, _file_itm_data.num_rows_per_source) = - _metadata->select_row_groups(_options.row_group_indices, + _metadata->select_row_groups(_sources, + _options.row_group_indices, _options.skip_rows, _options.num_rows, output_dtypes, From d67e4b5dce291213da6d321acf8d28975302f17a Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 9 Nov 2024 00:22:21 +0000 Subject: [PATCH 15/82] Minor bug fix --- cpp/src/io/parquet/reader_impl_helpers.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index b6348a3f897..da000edfd04 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1215,12 +1215,13 @@ aggregate_reader_metadata::select_row_groups( } } - // FIXME: Provide the actual condition for this if + // FIXME: Provide the actual condition for this if (true /* equality predicate provided */) { - filtered_row_group_indices = apply_bloom_filter_to_row_groups( + auto const bloom_filtered_row_groups = apply_bloom_filter_to_row_groups( sources, row_group_indices, output_dtypes, output_column_schemas, stream); - - if (filtered_row_group_indices.has_value()) { + // TODO: Can use a better logic here. + if (bloom_filtered_row_groups.has_value()) { + filtered_row_group_indices.value() = std::move(bloom_filtered_row_groups.value()); row_group_indices = host_span const>(filtered_row_group_indices.value()); } From 10471d466ac3985feab08410a76c5e42b2f6f458 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 9 Nov 2024 00:22:55 +0000 Subject: [PATCH 16/82] Apply style fix --- cpp/src/io/parquet/reader_apply_bloom_filters.cu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/reader_apply_bloom_filters.cu b/cpp/src/io/parquet/reader_apply_bloom_filters.cu index 2d1c20ba48c..fc85a0a1eb2 100644 --- a/cpp/src/io/parquet/reader_apply_bloom_filters.cu +++ b/cpp/src/io/parquet/reader_apply_bloom_filters.cu @@ -33,4 +33,4 @@ #include // TODO: Implement this -cuda::std::optional>> apply_bloom_filters() { return {}; } \ No newline at end of file +cuda::std::optional>> apply_bloom_filters() { return {}; } From 1e12662ca1aa75a5a2d4c8ce894df2ef8b2117d8 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 03:12:55 +0000 Subject: [PATCH 17/82] Some updates --- cpp/src/io/parquet/predicate_pushdown.cpp | 14 +++++++ cpp/src/io/parquet/reader_impl_helpers.cpp | 44 +++++++++++++--------- cpp/src/io/parquet/reader_impl_helpers.hpp | 2 + 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index cd3dcd2bce4..abc8978d8db 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -391,6 +391,7 @@ class stats_expression_converter : public ast::detail::expression_transformer { } // namespace std::optional>> aggregate_reader_metadata::filter_row_groups( + host_span const> sources, host_span const> row_group_indices, host_span output_dtypes, host_span output_column_schemas, @@ -491,6 +492,19 @@ std::optional>> aggregate_reader_metadata::fi } filtered_row_group_indices.push_back(std::move(filtered_row_groups)); } + + // If equality predicate + if (true /* is_equality_predicate */) { + filtered_row_group_indices = + apply_bloom_filter_to_row_groups( + sources, + filtered_row_group_indices.empty() ? input_row_group_indices : filtered_row_group_indices, + output_dtypes, + output_column_schemas, + stream) + .value(); + } + return {std::move(filtered_row_group_indices)}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index da000edfd04..7ca1ffc4253 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1079,6 +1079,20 @@ std::future read_bloom_filters_async( CompactProtocolReader cp{buffer->data(), buffer->size()}; cp.read(&header); + // Test if header is valid. + auto const is_header_valid = + (header.num_bytes % 32) == 0 and + header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and + header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and + header.hash.hash == BloomFilterHash::Hash::XXHASH; + + if (not is_header_valid) { + // Simply use an empty device buffer and move on. + bloom_filter_data[chunk] = rmm::device_buffer{}; + CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); + continue; + } + // Bloom filter header size auto const bloom_filter_header_size = static_cast(cp.bytecount()); auto const bitset_size = header.num_bytes; @@ -1106,6 +1120,9 @@ std::future read_bloom_filters_async( bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); } } + } else { + // Simply use an empty device buffer. + bloom_filter_data[chunk] = rmm::device_buffer{}; } } auto sync_fn = [](decltype(read_tasks) read_tasks) { @@ -1124,11 +1141,16 @@ aggregate_reader_metadata::apply_bloom_filter_to_row_groups( host_span output_column_schemas, rmm::cuda_stream_view stream) const { - // Number of row groups after filter_row_groups(). - auto const num_row_groups = std::accumulate( - row_group_indices.begin(), row_group_indices.end(), 0, [](auto& sum, auto const& rgis) { - return sum + rgis.size(); - }); + // Create row group indices. + // std::vector> filtered_row_group_indices; + // Number of total row groups to process. + auto const num_row_groups = std::accumulate(row_group_indices.begin(), + row_group_indices.end(), + 0, + [](size_type sum, auto const& per_file_row_groups) { + return sum + per_file_row_groups.size(); + }); + // Descriptors for all the chunks that make up the selected columns auto const num_input_columns = output_dtypes.size(); auto const num_chunks = num_row_groups * num_input_columns; @@ -1215,18 +1237,6 @@ aggregate_reader_metadata::select_row_groups( } } - // FIXME: Provide the actual condition for this - if (true /* equality predicate provided */) { - auto const bloom_filtered_row_groups = apply_bloom_filter_to_row_groups( - sources, row_group_indices, output_dtypes, output_column_schemas, stream); - // TODO: Can use a better logic here. - if (bloom_filtered_row_groups.has_value()) { - filtered_row_group_indices.value() = std::move(bloom_filtered_row_groups.value()); - row_group_indices = - host_span const>(filtered_row_group_indices.value()); - } - } - std::vector selection; auto [rows_to_skip, rows_to_read] = [&]() { if (not row_group_indices.empty()) { return std::pair{}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 82228599cd4..7faae405b4a 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -323,6 +323,7 @@ class aggregate_reader_metadata { /** * @brief Filters the row groups based on predicate filter * + * @param sources Lists of input datasources. * @param row_group_indices Lists of row groups to read, one per source * @param output_dtypes Datatypes of of output columns * @param output_column_schemas schema indices of output columns @@ -331,6 +332,7 @@ class aggregate_reader_metadata { * @return Filtered row group indices, if any is filtered. */ [[nodiscard]] std::optional>> filter_row_groups( + host_span const> sources, host_span const> row_group_indices, host_span output_dtypes, host_span output_column_schemas, From ee7217ca59649d75b6d4fb465e5e92257121626e Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 05:04:13 +0000 Subject: [PATCH 18/82] Move contents to a separate file --- cpp/src/io/parquet/bloom_filter_reader.cpp | 209 +++++++++++++++++++++ cpp/src/io/parquet/predicate_pushdown.cpp | 12 -- cpp/src/io/parquet/reader_impl_helpers.cpp | 184 +----------------- cpp/src/io/parquet/reader_impl_helpers.hpp | 19 +- 4 files changed, 218 insertions(+), 206 deletions(-) create mode 100644 cpp/src/io/parquet/bloom_filter_reader.cpp diff --git a/cpp/src/io/parquet/bloom_filter_reader.cpp b/cpp/src/io/parquet/bloom_filter_reader.cpp new file mode 100644 index 00000000000..346ccd22c58 --- /dev/null +++ b/cpp/src/io/parquet/bloom_filter_reader.cpp @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "compact_protocol_reader.hpp" +#include "io/parquet/parquet.hpp" +#include "reader_impl_helpers.hpp" + +#include + +#include +#include + +#include + +#include +#include +#include + +namespace cudf::io::parquet::detail { + +namespace { +/** + * @brief Asynchronously reads bloom filters to device. + * + * @param sources Dataset sources + * @param num_chunks Number of total column chunks to read + * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param bloom_filter_offsets Bloom filter offsets for all chunks + * @param bloom_filter_sizes Bloom filter sizes for all chunks + * @param chunk_source_map Association between each column chunk and its source + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return A future object for reading synchronization + */ +std::future read_bloom_filters_async( + host_span const> sources, + size_t num_chunks, + cudf::host_span bloom_filter_data, + cudf::host_span> bloom_filter_offsets, + cudf::host_span> bloom_filter_sizes, + std::vector const& chunk_source_map, + rmm::cuda_stream_view stream) +{ + // Read tasks for bloom filter data + std::vector> read_tasks; + + // Read bloom filters for all column chunks + std::for_each( + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(num_chunks), + [&](auto const chunk) { + // Read bloom filter if present + if (bloom_filter_offsets[chunk].has_value()) { + auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); + // If Bloom filter size (header + bitset) is available, just read the entire thing. + // Else just read 256 bytes which will contain the entire header and may contain the + // entire bitset as well. + auto constexpr bloom_filter_header_size_guess = 256; + auto const initial_read_size = + bloom_filter_sizes[chunk].value_or(bloom_filter_header_size_guess); + + // Read an initial buffer from source + auto& source = sources[chunk_source_map[chunk]]; + auto buffer = source->host_read(bloom_filter_offset, initial_read_size); + + // Deserialize the Bloom filter header from the buffer. + BloomFilterHeader header; + CompactProtocolReader cp{buffer->data(), buffer->size()}; + cp.read(&header); + + // Test if header is valid. + auto const is_header_valid = + (header.num_bytes % 32) == 0 and + header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and + header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and + header.hash.hash == BloomFilterHash::Hash::XXHASH; + + // Do not read if the bloom filter is invalid + if (not is_header_valid) { + CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); + return; + } + + // Bloom filter header size + auto const bloom_filter_header_size = static_cast(cp.bytecount()); + auto const bitset_size = header.num_bytes; + + // Check if we already read in the filter bitset in the initial read. + if (initial_read_size >= bloom_filter_header_size + bitset_size) { + bloom_filter_data[chunk] = + rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); + } + // Read the bitset from datasource. + else { + auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; + // Directly read to device if preferred + if (source->is_device_read_preferred(bitset_size)) { + bloom_filter_data[chunk] = rmm::device_buffer(bitset_size, stream); + auto future_read_size = + source->device_read_async(bitset_offset, + bitset_size, + static_cast(bloom_filter_data[chunk].data()), + stream); + + read_tasks.emplace_back(std::move(future_read_size)); + } else { + buffer = source->host_read(bitset_offset, bitset_size); + bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); + } + } + } + }); + auto sync_fn = [](decltype(read_tasks) read_tasks) { + for (auto& task : read_tasks) { + task.wait(); + } + }; + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); +} + +} // namespace + +std::vector aggregate_reader_metadata::read_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span column_schemas, + rmm::cuda_stream_view stream) const +{ + // Number of total row groups to process. + auto const num_row_groups = std::accumulate( + row_group_indices.begin(), + row_group_indices.end(), + size_t{0}, + [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); + + // Descriptors for all the chunks that make up the selected columns + auto const num_input_columns = column_schemas.size(); + auto const num_chunks = num_row_groups * num_input_columns; + + // Association between each column chunk and its source + std::vector chunk_source_map(num_chunks); + + // Keep track of column chunk file offsets + std::vector> bloom_filter_offsets(num_chunks); + std::vector> bloom_filter_sizes(num_chunks); + + // Gather all bloom filter offsets and sizes. + size_type chunk_count = 0; + + // For all data sources + std::for_each(thrust::make_counting_iterator(0), + thrust::make_counting_iterator(row_group_indices.size()), + [&](auto const src_index) { + // Get all row group indices in the data source + auto const& rg_indices = row_group_indices[src_index]; + // For all row groups + std::for_each(rg_indices.cbegin(), rg_indices.cend(), [&](auto const rg_index) { + // For all column chunks + std::for_each( + column_schemas.begin(), column_schemas.end(), [&](auto const schema_idx) { + auto& col_meta = get_column_metadata(rg_index, src_index, schema_idx); + + // Get bloom filter offsets and sizes + bloom_filter_offsets[chunk_count] = col_meta.bloom_filter_offset; + bloom_filter_sizes[chunk_count] = col_meta.bloom_filter_length; + + // Map each column chunk to its source index + chunk_source_map[chunk_count] = src_index; + chunk_count++; + }); + }); + }); + + // Do we have any bloom filters + if (std::any_of(bloom_filter_offsets.cbegin(), + bloom_filter_offsets.cend(), + [](auto const offset) { return offset.has_value(); })) { + // Create a vector to store bloom filter data + std::vector bloom_filter_data(num_chunks); + + // Wait on bloom filter read tasks + read_bloom_filters_async(sources, + num_chunks, + bloom_filter_data, + bloom_filter_offsets, + bloom_filter_sizes, + chunk_source_map, + stream) + .wait(); + // Return the vector + return bloom_filter_data; + } + return {}; +} + +} // namespace cudf::io::parquet::detail \ No newline at end of file diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index abc8978d8db..a977cfdeca7 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -493,18 +493,6 @@ std::optional>> aggregate_reader_metadata::fi filtered_row_group_indices.push_back(std::move(filtered_row_groups)); } - // If equality predicate - if (true /* is_equality_predicate */) { - filtered_row_group_indices = - apply_bloom_filter_to_row_groups( - sources, - filtered_row_group_indices.empty() ? input_row_group_indices : filtered_row_group_indices, - output_dtypes, - output_column_schemas, - stream) - .value(); - } - return {std::move(filtered_row_group_indices)}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index 7ca1ffc4253..9c05009b528 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1033,188 +1033,6 @@ std::vector aggregate_reader_metadata::get_pandas_index_names() con return names; } -/** - * @brief Asynchronously reads bloom filters to device. - * - * @param sources Dataset sources - * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk - * @param chunks List of chunk indices to read - * @param bloom_filter_offsets Bloom filter offsets for all chunks - * @param bloom_filter_sizes Bloom filter sizes for all chunks - * @param chunk_source_map Association between each column chunk and its source - * @param stream CUDA stream used for device memory operations and kernel launches - * - * @return A future object for reading synchronization - */ -std::future read_bloom_filters_async( - host_span const> sources, - cudf::host_span bloom_filter_data, - host_span chunks, - cudf::host_span> bloom_filter_offsets, - cudf::host_span> bloom_filter_sizes, - std::vector const& chunk_source_map, - rmm::cuda_stream_view stream) -{ - // Transfer bloom filter data - std::vector> read_tasks; - - // FIXME: Do not read all chunks. Instead use a list of chunks to read. - for (auto chunk : chunks) { - // Read bloom filter if present - if (bloom_filter_offsets[chunk].has_value()) { - auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); - // If Bloom filter size (header + bitset) is available, just read the entire thing. - // Else just read 256 bytes which will contain the entire header and may contain the - // entire bitset as well. - auto constexpr bloom_filter_header_size_guess = 256; - auto const initial_read_size = - bloom_filter_sizes[chunk].value_or(bloom_filter_header_size_guess); - - // Read an initial buffer from source - auto& source = sources[chunk_source_map[chunk]]; - auto buffer = source->host_read(bloom_filter_offset, initial_read_size); - - // Deserialize the Bloom filter header from the buffer. - BloomFilterHeader header; - CompactProtocolReader cp{buffer->data(), buffer->size()}; - cp.read(&header); - - // Test if header is valid. - auto const is_header_valid = - (header.num_bytes % 32) == 0 and - header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and - header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and - header.hash.hash == BloomFilterHash::Hash::XXHASH; - - if (not is_header_valid) { - // Simply use an empty device buffer and move on. - bloom_filter_data[chunk] = rmm::device_buffer{}; - CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); - continue; - } - - // Bloom filter header size - auto const bloom_filter_header_size = static_cast(cp.bytecount()); - auto const bitset_size = header.num_bytes; - - // Check if we already read in the filter bitset in the initial read. - if (initial_read_size >= bloom_filter_header_size + bitset_size) { - bloom_filter_data[chunk] = - rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); - } - // Read the bitset from datasource. - else { - auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; - // Directly read to device if preferred - if (source->is_device_read_preferred(bitset_size)) { - bloom_filter_data[chunk] = rmm::device_buffer(bitset_size, stream); - auto future_read_size = - source->device_read_async(bitset_offset, - bitset_size, - static_cast(bloom_filter_data[chunk].data()), - stream); - - read_tasks.emplace_back(std::move(future_read_size)); - } else { - buffer = source->host_read(bitset_offset, bitset_size); - bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); - } - } - } else { - // Simply use an empty device buffer. - bloom_filter_data[chunk] = rmm::device_buffer{}; - } - } - auto sync_fn = [](decltype(read_tasks) read_tasks) { - for (auto& task : read_tasks) { - task.wait(); - } - }; - return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); -} - -std::optional>> -aggregate_reader_metadata::apply_bloom_filter_to_row_groups( - host_span const> sources, - host_span const> row_group_indices, - host_span output_dtypes, - host_span output_column_schemas, - rmm::cuda_stream_view stream) const -{ - // Create row group indices. - // std::vector> filtered_row_group_indices; - // Number of total row groups to process. - auto const num_row_groups = std::accumulate(row_group_indices.begin(), - row_group_indices.end(), - 0, - [](size_type sum, auto const& per_file_row_groups) { - return sum + per_file_row_groups.size(); - }); - - // Descriptors for all the chunks that make up the selected columns - auto const num_input_columns = output_dtypes.size(); - auto const num_chunks = num_row_groups * num_input_columns; - - // Association between each column chunk and its source - std::vector chunk_source_map(num_chunks); - - // Keep track of column chunk file offsets - std::vector> bloom_filter_offsets(num_chunks); - std::vector> bloom_filter_sizes(num_chunks); - - // List of chunks to read - std::vector chunks_to_read{}; - chunks_to_read.reserve(num_chunks); - - size_type chunk_count = 0; - - // FIXME: We don't need to read bloom filters for all chunks. Only the ones belonging to the - // columns of our interest (equality predicate). - // For all data sources - for (size_t rg_source_index = 0; rg_source_index < row_group_indices.size(); rg_source_index++) { - auto const& rg_index = row_group_indices[rg_source_index]; - // For all row groups in a data source - for (auto const rgi : rg_index) { - // For all column chunks in a row group - for (size_t col_idx = 0; col_idx < output_dtypes.size(); col_idx++) { - auto const schema_idx = output_column_schemas[col_idx]; - auto& col_meta = get_column_metadata(rgi, rg_source_index, schema_idx); - - // Bloom filter offsets and sizes - bloom_filter_offsets[chunk_count] = col_meta.bloom_filter_offset; - bloom_filter_sizes[chunk_count] = col_meta.bloom_filter_length; - chunks_to_read.emplace_back(chunk_count); - - // Map each column chunk to its column index and its source index - chunk_source_map[chunk_count] = rg_source_index; - chunk_count++; - } - } - } - - // Do we have any bloom filters - if (std::any_of(bloom_filter_offsets.cbegin(), - bloom_filter_offsets.cend(), - [](auto const offset) { return offset.has_value(); })) { - // Initialize the vector of bloom filter data buffers - std::vector bloom_filter_data(num_chunks); - - // Wait on bloom filter read tasks - read_bloom_filters_async(sources, - bloom_filter_data, - chunks_to_read, - bloom_filter_offsets, - bloom_filter_sizes, - chunk_source_map, - stream) - .wait(); - - // Apply bloom filter to row groups - // return apply_bloom_filters(); - } - return {}; -} - std::tuple, std::vector> aggregate_reader_metadata::select_row_groups( host_span const> sources, @@ -1230,7 +1048,7 @@ aggregate_reader_metadata::select_row_groups( // if filter is not empty, then gather row groups to read after predicate pushdown if (filter.has_value()) { filtered_row_group_indices = filter_row_groups( - row_group_indices, output_dtypes, output_column_schemas, filter.value(), stream); + sources, row_group_indices, output_dtypes, output_column_schemas, filter.value(), stream); if (filtered_row_group_indices.has_value()) { row_group_indices = host_span const>(filtered_row_group_indices.value()); diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 7faae405b4a..b03aa9ec296 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -367,24 +367,21 @@ class aggregate_reader_metadata { rmm::cuda_stream_view stream) const; /** - * @brief Filters the row groups using bloom filters + * @brief Reads bloom filter bitsets for the specified columns from the given lists of row + * groups. * * @param sources Dataset sources - * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk - * @param begin_chunk Index of first column chunk to read - * @param end_chunk Index after the last column chunk to read - * @param bloom_filter_offsets Bloom filter offsets for all chunks - * @param bloom_filter_sizes Bloom filter sizes for all chunks - * @param chunk_source_map Association between each column chunk and its source + * @param row_group_indices Lists of row groups to read bloom filters from, one per source + * @param column_schemas Schema indices of columns whose bloom filters will be read * @param stream CUDA stream used for device memory operations and kernel launches * - * @return Filtered row group indices, if any is filtered. + * @return A list of bloom filter bitset device buffers flattened over column schemas over lists + * of row group indices */ - std::optional>> apply_bloom_filter_to_row_groups( + std::vector read_bloom_filters( host_span const> sources, host_span const> row_group_indices, - host_span output_dtypes, - host_span output_column_schemas, + host_span column_schemas, rmm::cuda_stream_view stream) const; /** From f8e6159e16d09e9c028f996c634ba9069d7c990e Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 05:06:19 +0000 Subject: [PATCH 19/82] Revert erroneous changes --- .../io/parquet/reader_apply_bloom_filters.cu | 36 ------------------- cpp/src/io/parquet/reader_impl_helpers.cpp | 5 --- 2 files changed, 41 deletions(-) delete mode 100644 cpp/src/io/parquet/reader_apply_bloom_filters.cu diff --git a/cpp/src/io/parquet/reader_apply_bloom_filters.cu b/cpp/src/io/parquet/reader_apply_bloom_filters.cu deleted file mode 100644 index fc85a0a1eb2..00000000000 --- a/cpp/src/io/parquet/reader_apply_bloom_filters.cu +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2024, NVIDIA CORPORATION. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @file bloom_filter_reader.cu - * @brief Bloom filter reader based row group filtration implementation - */ - -#include "parquet.hpp" -#include "parquet_common.hpp" - -#include - -#include - -#include -#include -#include -#include -#include - -// TODO: Implement this -cuda::std::optional>> apply_bloom_filters() { return {}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index 9c05009b528..ae720a9622d 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -25,16 +25,11 @@ #include -#include -#include - #include #include #include -#include #include -#include #include namespace cudf::io::parquet::detail { From 1886cabae52f2495e6b6071a15101f9c57b9d1a5 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 16 Nov 2024 05:13:54 +0000 Subject: [PATCH 20/82] Style and doc fix --- cpp/src/io/parquet/bloom_filter_reader.cpp | 2 +- cpp/src/io/parquet/parquet.hpp | 29 +++++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cpp b/cpp/src/io/parquet/bloom_filter_reader.cpp index 346ccd22c58..fabc70cc5f2 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cpp +++ b/cpp/src/io/parquet/bloom_filter_reader.cpp @@ -206,4 +206,4 @@ std::vector aggregate_reader_metadata::read_bloom_filters( return {}; } -} // namespace cudf::io::parquet::detail \ No newline at end of file +} // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/parquet.hpp b/cpp/src/io/parquet/parquet.hpp index 118ec43133b..ff8e7a63486 100644 --- a/cpp/src/io/parquet/parquet.hpp +++ b/cpp/src/io/parquet/parquet.hpp @@ -396,40 +396,45 @@ struct ColumnChunkMetaData { }; /** - * @brief The algorithm used in Bloom filter. - **/ + * @brief The algorithm used in bloom filter + */ struct BloomFilterAlgorithm { - /** Block-based Bloom filter. **/ + // Block-based Bloom filter. enum Algorithm { UNDEFINED, SPLIT_BLOCK }; Algorithm algorithm{Algorithm::SPLIT_BLOCK}; }; /** - * @brief The hash function used in Bloom filter. This function takes the hash of a column value - * using plain encoding. - **/ + * @brief The hash function used in Bloom filter + */ struct BloomFilterHash { - /** xxHash Strategy. **/ + // xxHash_64 enum Hash { UNDEFINED, XXHASH }; Hash hash{Hash::XXHASH}; }; /** - * @brief The compression used in the Bloom filter. - **/ + * @brief The compression used in the bloom filter + */ struct BloomFilterCompression { enum Compression { UNDEFINED, UNCOMPRESSED }; Compression compression{Compression::UNCOMPRESSED}; }; +/** + * @brief Bloom filter header struct + * + * The bloom filter data of a column chunk stores this header at the beginning + * following by the filter bitset. + */ struct BloomFilterHeader { // The size of bitset in bytes int32_t num_bytes; - // The algorithm for setting bits. * + // The algorithm for setting bits BloomFilterAlgorithm algorithm; - // The hash function used for Bloom filter. * + // The hash function used for bloom filter BloomFilterHash hash; - // The compression used in the Bloom filter * + // The compression used in the bloom filter BloomFilterCompression compression; }; From be228b31f7664c3d7bd12ce398c24e785b871400 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 19 Nov 2024 03:58:42 +0000 Subject: [PATCH 21/82] Get equality predicate col indices --- ...lter_reader.cpp => bloom_filter_reader.cu} | 189 ++++++++++++++++++ cpp/src/io/parquet/predicate_pushdown.cpp | 8 +- cpp/src/io/parquet/reader_impl_helpers.hpp | 20 ++ 3 files changed, 216 insertions(+), 1 deletion(-) rename cpp/src/io/parquet/{bloom_filter_reader.cpp => bloom_filter_reader.cu} (51%) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cpp b/cpp/src/io/parquet/bloom_filter_reader.cu similarity index 51% rename from cpp/src/io/parquet/bloom_filter_reader.cpp rename to cpp/src/io/parquet/bloom_filter_reader.cu index fabc70cc5f2..89f448de05f 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cpp +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -18,14 +18,30 @@ #include "io/parquet/parquet.hpp" #include "reader_impl_helpers.hpp" +#include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include #include +#include #include +#include #include #include @@ -102,6 +118,37 @@ std::future read_bloom_filters_async( if (initial_read_size >= bloom_filter_header_size + bitset_size) { bloom_filter_data[chunk] = rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); + + /* MH: Remove this + using word_type = uint32_t; + + thrust::for_each(rmm::exec_policy(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(1), + [words = reinterpret_cast(bloom_filter_data[chunk].data()), + num_blocks = bloom_filter_data[chunk].size() / sizeof(uint32_t), + chunk = chunk, + stream = stream] __device__(auto idx) { + using key_type = cuda::std::string; + using policy_type = + cuco::bloom_filter_policy, std::uint32_t, + 8>; + // using arrow_policy_type = cuco::arrow_filter_policy; + cuco::bloom_filter_ref, + cuco::thread_scope_device, + policy_type> + filter{words, size_t{num_blocks}, {}, {8}}; + // cuda::std::string key{"third-136666"}; + // filter.add("third-136666"); + + cuco::xxhash_64 hasher{}; + cuda::std::array val{"third-136666"}; + auto hash = + hasher.compute_hash(reinterpret_cast(val.data()), val.size()); if + (filter.contains(hash)) { printf("Filter chunk: %lu contains key: third-136666\n", chunk); + } + });*/ } // Read the bitset from datasource. else { @@ -123,16 +170,158 @@ std::future read_bloom_filters_async( } } }); + auto sync_fn = [](decltype(read_tasks) read_tasks) { for (auto& task : read_tasks) { task.wait(); } }; + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); } +/** + * @brief Collects column indices with an equality predicate in the AST expression. + * This is used in row group filtering based on bloom filters. + */ +class equality_predicate_converter : public ast::detail::expression_transformer { + public: + equality_predicate_converter(ast::expression const& expr, size_type const& num_columns) + : _num_columns{num_columns} + { + expr.accept(*this); + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) + */ + std::reference_wrapper visit(ast::literal const& expr) override + { + _equality_expr = std::reference_wrapper(expr); + return expr; + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) + */ + std::reference_wrapper visit(ast::column_reference const& expr) override + { + CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, + "Equality AST supports only left table"); + CUDF_EXPECTS(expr.get_column_index() < _num_columns, + "Column index cannot be more than number of columns in the table"); + _equality_expr = std::reference_wrapper(expr); + return expr; + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) + */ + std::reference_wrapper visit( + ast::column_name_reference const& expr) override + { + CUDF_FAIL("Column name reference is not supported in equality AST"); + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + */ + std::reference_wrapper visit(ast::operation const& expr) override + { + using cudf::ast::ast_operator; + auto const operands = expr.get_operands(); + auto const op = expr.get_operator(); + + if (auto* v = dynamic_cast(&operands[0].get())) { + // First operand should be column reference, second should be literal. + CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, + "Only binary operations are supported on column reference"); + CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, + "Second operand of binary operation with column reference must be a literal"); + v->accept(*this); + if (op == ast_operator::EQUAL) { equality_col_idx.emplace_back(v->get_column_index()); } + } else { + auto new_operands = visit_operands(operands); + if (cudf::ast::detail::ast_operator_arity(op) == 2) { + _operators.emplace_back(op, new_operands.front(), new_operands.back()); + } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { + _operators.emplace_back(op, new_operands.front()); + } + } + _equality_expr = std::reference_wrapper(_operators.back()); + return std::reference_wrapper(_operators.back()); + } + + /** + * @brief Returns a list of column indices with an equality predicate in the AST expression. + * + * @return List of column indices + */ + [[nodiscard]] std::vector get_equality_col_idx() const + { + return equality_col_idx; + } + + private: + std::vector> visit_operands( + cudf::host_span const> operands) + { + std::vector> transformed_operands; + for (auto const& operand : operands) { + auto const new_operand = operand.get().accept(*this); + transformed_operands.push_back(new_operand); + } + return transformed_operands; + } + std::optional> _equality_expr; + std::vector equality_col_idx; + size_type _num_columns; + std::list _col_ref; + std::list _operators; +}; + } // namespace +std::optional>> aggregate_reader_metadata::apply_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + std::reference_wrapper filter, + rmm::cuda_stream_view stream) const +{ + auto const num_cols = output_dtypes.size(); + CUDF_EXPECTS(output_dtypes.size() == output_column_schemas.size(), + "Mismatched size between lists of output column dtypes and output column schema"); + auto mr = cudf::get_current_device_resource_ref(); + std::vector> cols; + // MH: How do I test for nested or non-comparable columns here? + cols.emplace_back(cudf::make_numeric_column( + data_type{cudf::type_id::INT32}, num_cols, rmm::device_buffer{}, 0, stream, mr)); + + auto mutable_col_idx = cols.back()->mutable_view(); + + thrust::sequence(rmm::exec_policy(stream), + mutable_col_idx.begin(), + mutable_col_idx.end(), + 0); + + auto equality_table = cudf::table(std::move(cols)); + + // Converts AST to EqualityAST with reference to min, max columns in above `stats_table`. + equality_predicate_converter equality_expr{filter.get(), static_cast(num_cols)}; + auto equality_col_schemas = equality_expr.get_equality_col_idx(); + + // Convert column indices to column schema indices + std::for_each(equality_col_schemas.begin(), equality_col_schemas.end(), [&](auto& col_idx) { + col_idx = output_column_schemas[col_idx]; + }); + + std::ignore = read_bloom_filters(sources, row_group_indices, equality_col_schemas, stream); + + return std::nullopt; +} + std::vector aggregate_reader_metadata::read_bloom_filters( host_span const> sources, host_span const> row_group_indices, diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index a977cfdeca7..eb30cc6955a 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -479,7 +479,9 @@ std::optional>> aggregate_reader_metadata::fi is_row_group_required.cend(), [](auto i) { return bool(i); }) or predicate.null_count() == predicate.size()) { - return std::nullopt; + // Call with input_row_group_indices + return apply_bloom_filters( + sources, input_row_group_indices, output_dtypes, output_column_schemas, filter, stream); } size_type is_required_idx = 0; for (auto const& input_row_group_index : input_row_group_indices) { @@ -493,6 +495,10 @@ std::optional>> aggregate_reader_metadata::fi filtered_row_group_indices.push_back(std::move(filtered_row_groups)); } + // Call with filtered_row_group_indices + return apply_bloom_filters( + sources, filtered_row_group_indices, output_dtypes, output_column_schemas, filter, stream); + return {std::move(filtered_row_group_indices)}; } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index b03aa9ec296..75edfdb7f7b 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -339,6 +339,26 @@ class aggregate_reader_metadata { std::reference_wrapper filter, rmm::cuda_stream_view stream) const; + /** + * @brief Filters the row groups using bloom filters + * + * @param sources Dataset sources + * @param row_group_indices Lists of row groups to read, one per source + * @param output_dtypes Datatypes of of output columns + * @param output_column_schemas schema indices of output columns + * @param filter AST expression to filter row groups based on Column chunk statistics + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return Filtered row group indices, if any is filtered. + */ + [[nodiscard]] std::optional>> apply_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + std::reference_wrapper filter, + rmm::cuda_stream_view stream) const; + /** * @brief Filters and reduces down to a selection of row groups * From aaf355e1b2efb830ed72b2bf09957ae3a1e95e76 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 20 Nov 2024 02:45:46 +0000 Subject: [PATCH 22/82] Enable `arrow_filter_policy` and `span` types in bloom filter. --- cpp/src/io/parquet/arrow_filter_policy.cuh | 187 +++++++++++++++++++++ cpp/src/io/parquet/bloom_filter_reader.cu | 89 ++++++---- 2 files changed, 246 insertions(+), 30 deletions(-) create mode 100644 cpp/src/io/parquet/arrow_filter_policy.cuh diff --git a/cpp/src/io/parquet/arrow_filter_policy.cuh b/cpp/src/io/parquet/arrow_filter_policy.cuh new file mode 100644 index 00000000000..95edf68cab4 --- /dev/null +++ b/cpp/src/io/parquet/arrow_filter_policy.cuh @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace cuco { + +/** + * @brief A policy that defines how Arrow Block-Split Bloom Filter generates and stores a key's + * fingerprint. + * + * @note: This file is a part of cuCollections. Copied here until we get a cuco bump for cudf. + * + * Reference: + * https://github.com/apache/arrow/blob/be1dcdb96b030639c0b56955c4c62f9d6b03f473/cpp/src/parquet/bloom_filter.cc#L219-L230 + * + * Example: + * @code{.cpp} + * template + * void bulk_insert_and_eval_arrow_policy_bloom_filter(device_vector const& positive_keys, + * device_vector const& negative_keys) + * { + * using policy_type = cuco::arrow_filter_policy; + * + * // Warn or throw if the number of filter blocks is greater than maximum used by Arrow policy. + * static_assert(NUM_FILTER_BLOCKS <= policy_type::max_filter_blocks, "NUM_FILTER_BLOCKS must be + * in range: [1, 4194304]"); + * + * // Create a bloom filter with Arrow policy + * cuco::bloom_filter, + * cuda::thread_scope_device, policy_type> filter{NUM_FILTER_BLOCKS}; + * + * // Add positive keys to the bloom filter + * filter.add(positive_keys.begin(), positive_keys.end()); + * + * auto const num_tp = positive_keys.size(); + * auto const num_tn = negative_keys.size(); + * + * // Vectors to store query results. + * thrust::device_vector true_positive_result(num_tp, false); + * thrust::device_vector true_negative_result(num_tn, false); + * + * // Query the bloom filter for the inserted keys. + * filter.contains(positive_keys.begin(), positive_keys.end(), true_positive_result.begin()); + * + * // We should see a true-positive rate of 1. + * float true_positive_rate = float(thrust::count(thrust::device, + * true_positive_result.begin(), true_positive_result.end(), true)) / float(num_tp); + * + * // Query the bloom filter for the non-inserted keys. + * filter.contains(negative_keys.begin(), negative_keys.end(), true_negative_result.begin()); + * + * // We may see a false-positive rate > 0 depending on the number of bits in the + * // filter and the number of hashes used per key. + * float false_positive_rate = float(thrust::count(thrust::device, + * true_negative_result.begin(), true_negative_result.end(), true)) / float(num_tn); + * } + * @endcode + * + * @tparam Key The type of the values to generate a fingerprint for. + */ +template +class arrow_filter_policy { + public: + using hasher = Hash; ///< Hash function for Arrow bloom filter policy + using word_type = std::uint32_t; ///< uint32_t for Arrow bloom filter policy + using hash_argument_type = typename hasher::argument_type; ///< Hash function input type + using hash_result_type = decltype(std::declval()( + std::declval())); ///< hash function output type + + static constexpr uint32_t bits_set_per_block = 8; ///< hardcoded bits set per Arrow filter block + static constexpr uint32_t words_per_block = 8; ///< hardcoded words per Arrow filter block + + static constexpr std::uint32_t bytes_per_filter_block = + 32; ///< Number of bytes in one Arrow filter block + static constexpr std::uint32_t max_arrow_filter_bytes = + 128 * 1024 * 1024; ///< Max bytes in Arrow bloom filter + static constexpr std::uint32_t max_filter_blocks = + (max_arrow_filter_bytes / + bytes_per_filter_block); ///< Max sub-filter blocks allowed in Arrow bloom filter + + private: + // Arrow's block-based bloom filter algorithm needs these eight odd SALT values to calculate + // eight indexes of bit to set, one bit in each 32-bit (uint32_t) word. + __device__ static constexpr cuda::std::array SALT() + { + return {0x47b6137bU, + 0x44974d91U, + 0x8824ad5bU, + 0xa2b7289dU, + 0x705495c7U, + 0x2df1424bU, + 0x9efc4947U, + 0x5c6bfb31U}; + } + + public: + /** + * @brief Constructs the `arrow_filter_policy` object. + * + * @note The number of filter blocks with Arrow policy must be in the + * range of [1, 4194304]. If the bloom filter is constructed with a larger + * number of blocks, only the first 4194304 (128MB) blocks will be used. + * + * @param hash Hash function used to generate a key's fingerprint + */ + __host__ __device__ constexpr arrow_filter_policy(hasher hash = {}) : hash_{hash} {} + + /** + * @brief Generates the hash value for a given key. + * + * @param key The key to hash + * + * @return The hash value of the key + */ + __device__ constexpr hash_result_type hash(hash_argument_type const& key) const + { + return hash_(key); + } + + /** + * @brief Determines the filter block a key is added into. + * + * @note The number of filter blocks with Arrow policy must be in the + * range of [1, 4194304]. Passing a larger `num_blocks` will still + * upperbound the number of blocks used to the mentioned range. + * + * @tparam Extent Size type that is used to determine the number of blocks in the filter + * + * @param hash Hash value of the key + * @param num_blocks Number of block in the filter + * + * @return The block index for the given key's hash value + */ + template + __device__ constexpr auto block_index(hash_result_type hash, Extent num_blocks) const + { + constexpr auto hash_bits = cuda::std::numeric_limits::digits; + // TODO: assert if num_blocks > max_filter_blocks + auto const max_blocks = cuda::std::min(num_blocks, max_filter_blocks); + // Make sure we are only contained withing the `max_filter_blocks` blocks + return static_cast(((hash >> hash_bits) * max_blocks) >> hash_bits) % max_blocks; + } + + /** + * @brief Determines the fingerprint pattern for a word/segment within the filter block for a + * given key's hash value. + * + * @param hash Hash value of the key + * @param word_index Target word/segment within the filter block + * + * @return The bit pattern for the word/segment in the filter block + */ + __device__ constexpr word_type word_pattern(hash_result_type hash, std::uint32_t word_index) const + { + // SALT array to calculate bit indexes for the current word + auto constexpr salt = SALT(); + word_type const key = static_cast(hash); + return word_type{1} << ((key * salt[word_index]) >> 27); + } + + private: + hasher hash_; +}; + +} // namespace cuco \ No newline at end of file diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 89f448de05f..5178fbba3c1 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -14,6 +14,7 @@ * limitations under the License. */ +#include "arrow_filter_policy.cuh" #include "compact_protocol_reader.hpp" #include "io/parquet/parquet.hpp" #include "reader_impl_helpers.hpp" @@ -36,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -48,6 +50,61 @@ namespace cudf::io::parquet::detail { namespace { + +/** + * @brief Temporary function that tests for key `third-037493` in bloom filters of column `c2` in + * test Parquet file. + * + * @param buffer Device buffer containing bloom filter bitset + * @param chunk Current row group index + * @param stream CUDA stream used for device memory operations and kernel launches + * + */ +void check_arbitrary_string_key(rmm::device_buffer const& buffer, + size_t chunk, + rmm::cuda_stream_view stream) +{ + using word_type = cuda::std::uint32_t; + using key_type = cuda::std::span; + using policy_type = cuco::arrow_filter_policy>; + + thrust::for_each( + rmm::exec_policy_nosync(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(1), + [bitset = const_cast(reinterpret_cast(buffer.data())), + num_blocks = static_cast(buffer.size()) / sizeof(uint32_t), + chunk = chunk, + stream = stream] __device__(auto idx) { + // using arrow_policy_type = cuco::arrow_filter_policy; + cuco::bloom_filter_ref, + cuco::thread_scope_device, + policy_type> + filter{ + bitset, + num_blocks, + {}, // scope + {0} // policy + }; + + // literal to search + cudf::string_view literal("third-037493", sizeof("third-037493")); + // convert to a cuda::std::span key to search + cuda::std::span const key( + const_cast(reinterpret_cast(literal.data())), + static_cast(literal.length())); + // Search in the filter + if (filter.contains(key)) { + printf("YES: Filter chunk: %lu contains key: third-037493\n", chunk); + } else { + printf("NO: Filter chunk: %lu does not contain key: third-037493\n", chunk); + } + }); + + stream.synchronize_no_throw(); +} + /** * @brief Asynchronously reads bloom filters to device. * @@ -119,36 +176,8 @@ std::future read_bloom_filters_async( bloom_filter_data[chunk] = rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); - /* MH: Remove this - using word_type = uint32_t; - - thrust::for_each(rmm::exec_policy(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(1), - [words = reinterpret_cast(bloom_filter_data[chunk].data()), - num_blocks = bloom_filter_data[chunk].size() / sizeof(uint32_t), - chunk = chunk, - stream = stream] __device__(auto idx) { - using key_type = cuda::std::string; - using policy_type = - cuco::bloom_filter_policy, std::uint32_t, - 8>; - // using arrow_policy_type = cuco::arrow_filter_policy; - cuco::bloom_filter_ref, - cuco::thread_scope_device, - policy_type> - filter{words, size_t{num_blocks}, {}, {8}}; - // cuda::std::string key{"third-136666"}; - // filter.add("third-136666"); - - cuco::xxhash_64 hasher{}; - cuda::std::array val{"third-136666"}; - auto hash = - hasher.compute_hash(reinterpret_cast(val.data()), val.size()); if - (filter.contains(hash)) { printf("Filter chunk: %lu contains key: third-136666\n", chunk); - } - });*/ + // MH: TODO: Temporary test. Remove me!! + check_arbitrary_string_key(bloom_filter_data[chunk], chunk, stream); } // Read the bitset from datasource. else { From e92324ebe0eaa1cb0a1a214f713ffe4aba36743f Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 21 Nov 2024 08:41:10 +0000 Subject: [PATCH 23/82] Successfully search bloom filter --- cpp/src/io/parquet/bloom_filter_reader.cu | 148 ++++++++++++++++++---- 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 5178fbba3c1..3c0c906a448 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -22,22 +22,18 @@ #include #include #include -#include #include -#include #include #include -#include +#include #include #include #include #include -#include +#include #include -#include -#include #include #include #include @@ -46,11 +42,115 @@ #include #include #include +#include namespace cudf::io::parquet::detail { namespace { +std::pair, std::vector> generate_chars_and_offsets(size_t num_keys) +{ + static std::vector const strings{"first", + "second", + "third", + "fourth", + "fifth", + "sixth", + "seventh", + "eighth", + "ninth", + "tenth", + "eleventh", + "twelfth", + "thirteenth", + "fourteenth", + "fifteenth", + "sixteenth", + "seventeenth", + "eighteenth"}; + + auto constexpr seed = 0xf00d; + /*static*/ std::mt19937 engine{seed}; + /*static*/ std::uniform_int_distribution dist{}; + + std::vector offsets(num_keys + 1); + std::vector chars; + chars.reserve(12 * num_keys); // 12 is the length of "seventeenth", the largest string + size_t offset = 0; + offsets[0] = size_t{0}; + std::generate_n(offsets.begin() + 1, num_keys, [&]() { + auto const& string = strings[dist(engine) % strings.size()]; + auto const char_ptr = const_cast(string.data()); + chars.insert(chars.end(), char_ptr, char_ptr + string.length()); + offset += string.length(); + return offset; + }); + return {std::move(chars), std::move(offsets)}; +} + +void validate_filter_bitwise(rmm::cuda_stream_view stream) +{ + std::size_t constexpr num_filter_blocks = 4; + std::size_t constexpr num_keys = 50; + + // Generate strings data + auto const [h_chars, h_offsets] = generate_chars_and_offsets(num_keys); + auto const mr = cudf::get_current_device_resource_ref(); + auto d_chars = cudf::detail::make_device_uvector_async(h_chars, stream, mr); + auto d_offsets = cudf::detail::make_device_uvector_async(h_offsets, stream, mr); + + rmm::device_uvector d_keys(num_keys, stream); + thrust::transform(rmm::exec_policy_nosync(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(num_keys), + d_keys.begin(), + [chars = thrust::raw_pointer_cast(d_chars.data()), + offsets = thrust::raw_pointer_cast(d_offsets.data())] __device__(auto idx) { + return cudf::string_view{ + chars + offsets[idx], + static_cast(offsets[idx + 1] - offsets[idx])}; + }); + + using key_type = cudf::string_view; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + using filter_type = cuco::bloom_filter, + cuda::thread_scope_device, + policy_type, + cudf::detail::cuco_allocator>; + // Spawn a bloom filter + filter_type filter{ + num_filter_blocks, + cuco::thread_scope_device, + {hasher_type{0}}, + cudf::detail::cuco_allocator{rmm::mr::polymorphic_allocator{}, stream}, + stream}; + + // Add strings to the bloom filter + filter.add(d_keys.begin(), d_keys.end(), stream); + + using word_type = filter_type::word_type; + + // Number of words in the filter + size_t const num_words = filter.block_extent() * filter.words_per_block; + + // Get the filter bitset words + cudf::device_span filter_bitset(filter.data(), num_words); + // Expected filter bitset words + rmm::device_vector const expected_bitset{ + 4194306, 4194305, 2359296, 1073774592, 524544, 1024, 268443648, 8519680, + 2147500040, 8421380, 269500416, 4202624, 8396802, 100665344, 2147747840, 5243136, + 131146, 655364, 285345792, 134222340, 545390596, 2281717768, 51201, 41943553, + 1619656708, 67441680, 8462730, 361220, 2216738864, 587333888, 4219272, 873463873}; + + CUDF_EXPECTS(thrust::equal(rmm::exec_policy(stream), + expected_bitset.cbegin(), + expected_bitset.cend(), + filter_bitset.begin()), + "Bloom filter bitset mismatched"); +} + /** * @brief Temporary function that tests for key `third-037493` in bloom filters of column `c2` in * test Parquet file. @@ -64,16 +164,21 @@ void check_arbitrary_string_key(rmm::device_buffer const& buffer, size_t chunk, rmm::cuda_stream_view stream) { - using word_type = cuda::std::uint32_t; - using key_type = cuda::std::span; - using policy_type = cuco::arrow_filter_policy>; + using key_type = cudf::string_view; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + using word_type = policy_type::word_type; + + auto constexpr word_size = sizeof(word_type); + auto constexpr words_per_block = policy_type::words_per_block; + auto const num_filter_blocks = buffer.size() / (word_size * words_per_block); thrust::for_each( - rmm::exec_policy_nosync(stream), + rmm::exec_policy(stream), thrust::make_counting_iterator(0), thrust::make_counting_iterator(1), [bitset = const_cast(reinterpret_cast(buffer.data())), - num_blocks = static_cast(buffer.size()) / sizeof(uint32_t), + num_blocks = num_filter_blocks, chunk = chunk, stream = stream] __device__(auto idx) { // using arrow_policy_type = cuco::arrow_filter_policy; @@ -81,19 +186,13 @@ void check_arbitrary_string_key(rmm::device_buffer const& buffer, cuco::extent, cuco::thread_scope_device, policy_type> - filter{ - bitset, - num_blocks, - {}, // scope - {0} // policy - }; + filter{bitset, + num_blocks, + {}, // scope + {hasher_type{0}}}; // literal to search - cudf::string_view literal("third-037493", sizeof("third-037493")); - // convert to a cuda::std::span key to search - cuda::std::span const key( - const_cast(reinterpret_cast(literal.data())), - static_cast(literal.length())); + cudf::string_view key("third-037493", 12); // Search in the filter if (filter.contains(key)) { printf("YES: Filter chunk: %lu contains key: third-037493\n", chunk); @@ -101,8 +200,6 @@ void check_arbitrary_string_key(rmm::device_buffer const& buffer, printf("NO: Filter chunk: %lu does not contain key: third-037493\n", chunk); } }); - - stream.synchronize_no_throw(); } /** @@ -206,6 +303,9 @@ std::future read_bloom_filters_async( } }; + // MH: Remove me. Bitwise validation for bloom filter + validate_filter_bitwise(stream); + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); } From 0b1719d61d0838ecc57cb3f6c7605d656c5491b6 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 21 Nov 2024 19:03:22 +0000 Subject: [PATCH 24/82] style fix --- cpp/src/io/parquet/arrow_filter_policy.cuh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/arrow_filter_policy.cuh b/cpp/src/io/parquet/arrow_filter_policy.cuh index 95edf68cab4..dfb6745bed8 100644 --- a/cpp/src/io/parquet/arrow_filter_policy.cuh +++ b/cpp/src/io/parquet/arrow_filter_policy.cuh @@ -184,4 +184,4 @@ class arrow_filter_policy { hasher hash_; }; -} // namespace cuco \ No newline at end of file +} // namespace cuco From ef3a26212e38759f2aae8b1dbddb07576455d186 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Fri, 22 Nov 2024 02:35:00 +0000 Subject: [PATCH 25/82] Code cleanup --- cpp/src/io/parquet/bloom_filter_reader.cu | 80 +++++++++++++---------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 3c0c906a448..6eed5c5dd21 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -34,8 +34,8 @@ #include #include -#include #include +#include #include #include @@ -155,51 +155,61 @@ void validate_filter_bitwise(rmm::cuda_stream_view stream) * @brief Temporary function that tests for key `third-037493` in bloom filters of column `c2` in * test Parquet file. * + * @tparam Key Type of the key to query bloom filter. + * * @param buffer Device buffer containing bloom filter bitset * @param chunk Current row group index * @param stream CUDA stream used for device memory operations and kernel launches * */ -void check_arbitrary_string_key(rmm::device_buffer const& buffer, - size_t chunk, - rmm::cuda_stream_view stream) +template +[[nodiscard]] bool check_arbitrary_string_key(rmm::device_buffer const& buffer, + Key const& literal, + size_t chunk_idx, + rmm::cuda_stream_view stream) { - using key_type = cudf::string_view; + using key_type = Key; using hasher_type = cudf::hashing::detail::XXHash_64; using policy_type = cuco::arrow_filter_policy; - using word_type = policy_type::word_type; + using word_type = typename policy_type::word_type; + // Filter properties auto constexpr word_size = sizeof(word_type); auto constexpr words_per_block = policy_type::words_per_block; auto const num_filter_blocks = buffer.size() / (word_size * words_per_block); - thrust::for_each( - rmm::exec_policy(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(1), - [bitset = const_cast(reinterpret_cast(buffer.data())), - num_blocks = num_filter_blocks, - chunk = chunk, - stream = stream] __device__(auto idx) { - // using arrow_policy_type = cuco::arrow_filter_policy; - cuco::bloom_filter_ref, - cuco::thread_scope_device, - policy_type> - filter{bitset, - num_blocks, - {}, // scope - {hasher_type{0}}}; - - // literal to search - cudf::string_view key("third-037493", 12); - // Search in the filter - if (filter.contains(key)) { - printf("YES: Filter chunk: %lu contains key: third-037493\n", chunk); - } else { - printf("NO: Filter chunk: %lu does not contain key: third-037493\n", chunk); - } - }); + // Bitset ptr must be a non-const to be used in bloom_filter_ref. + auto bitset = const_cast(reinterpret_cast(buffer.data())); + + // Create a bloom filter view + cuco:: + bloom_filter_ref, cuco::thread_scope_device, policy_type> + filter{bitset, + num_filter_blocks, + {}, // scope + {hasher_type{0}}}; + + // Copy over the key (literal) to device + rmm::device_buffer d_key{literal.data(), static_cast(literal.size_bytes()), stream}; + + // Query literal in bloom filter. + bool const is_literal_found = + thrust::count_if(rmm::exec_policy(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(1), + [filter = filter, + key_ptr = reinterpret_cast(d_key.data()), + key_size = static_cast(d_key.size())] __device__(auto idx) { + auto const d_key = cudf::string_view{key_ptr, key_size}; + return filter.contains(d_key); + }); + + // MH: Temporary + auto const found_string = (is_literal_found) ? "YES" : "NO"; + std::cout << "Literal: third-037493 found in chunk " << chunk_idx << ": " << found_string + << std::endl; + + return is_literal_found; } /** @@ -274,7 +284,9 @@ std::future read_bloom_filters_async( rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); // MH: TODO: Temporary test. Remove me!! - check_arbitrary_string_key(bloom_filter_data[chunk], chunk, stream); + cudf::string_view literal{"third-037493", 12}; + std::ignore = + check_arbitrary_string_key(bloom_filter_data[chunk], literal, chunk, stream); } // Read the bitset from datasource. else { From 051be2d6caad8b4ad78b0a9d5e0f2880f7b37198 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Mon, 25 Nov 2024 21:30:54 +0000 Subject: [PATCH 26/82] add tests --- cpp/tests/CMakeLists.txt | 1 + cpp/tests/hashing/bloom_filter_test.cu | 95 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 cpp/tests/hashing/bloom_filter_test.cu diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 8928d27a871..94a5063d150 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -182,6 +182,7 @@ ConfigureTest(DATETIME_OPS_TEST datetime/datetime_ops_test.cpp) # * hashing tests --------------------------------------------------------------------------------- ConfigureTest( HASHING_TEST + hashing/bloom_filter_test.cu hashing/md5_test.cpp hashing/murmurhash3_x86_32_test.cpp hashing/murmurhash3_x64_128_test.cpp diff --git a/cpp/tests/hashing/bloom_filter_test.cu b/cpp/tests/hashing/bloom_filter_test.cu new file mode 100644 index 00000000000..e0e98dd84a0 --- /dev/null +++ b/cpp/tests/hashing/bloom_filter_test.cu @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* +#include +#include +#include +#include + +#include +#include +#include + +#include + +using StringType = cudf::string_view; + +template +class BloomFilter_TestTyped : public cudf::test::BaseFixture {}; + +TYPED_TEST_SUITE(BloomFilter_TestTyped, StringType); + +TYPED_TEST(BloomFilter_TestTyped, TestStrings) +{ + using key_type = StringType; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + + std::size_t constexpr num_filter_blocks = 4; + std::size_t constexpr num_keys = 50; + auto stream = cudf::get_default_stream(); + + // strings data + auto data = cudf::test::strings_column_wrapper( + {"seventh", "fifteenth", "second", "tenth", "fifth", "first", + "seventh", "tenth", "ninth", "ninth", "seventeenth", "eighteenth", + "thirteenth", "fifth", "fourth", "twelfth", "second", "second", + "fourth", "seventh", "seventh", "tenth", "thirteenth", "seventeenth", + "fifth", "seventeenth", "eighth", "fourth", "second", "eighteenth", + "fifteenth", "second", "seventeenth", "thirteenth", "eighteenth", "fifth", + "seventh", "tenth", "fourteenth", "first", "fifth", "fifth", + "tenth", "thirteenth", "fourteenth", "third", "third", "sixth", + "first", "third"}); + auto d_column = cudf::column_device_view::create(data); + + // Spawn a bloom filter + cuco::bloom_filter, + cuda::thread_scope_device, + policy_type, + cudf::detail::cuco_allocator> + filter{num_filter_blocks, + cuco::thread_scope_device, + {hasher_type{0}}, + cudf::detail::cuco_allocator{rmm::mr::polymorphic_allocator{}, stream}, + stream}; + + // Add strings to the bloom filter + auto col = table.column(0); + filter.add(d_column->begin(), d_column->end(), stream); + + // Number of words in the filter + cudf::size_type const num_words = filter.block_extent() * filter.words_per_block; + + auto const output = cudf::column_view{ + cudf::data_type{cudf::type_id::UINT32}, num_words, filter.data(), nullptr, 0, 0, {}}; + + using word_type = filter_type::word_type; + + // Expected filter bitset words computed using Arrow implementation here: + // https://godbolt.org/z/oKfqcPWbY + auto expected = cudf::test::fixed_width_column_wrapper( + {4194306, 4194305, 2359296, 1073774592, 524544, 1024, 268443648, 8519680, + 2147500040, 8421380, 269500416, 4202624, 8396802, 100665344, 2147747840, 5243136, + 131146, 655364, 285345792, 134222340, 545390596, 2281717768, 51201, 41943553, + 1619656708, 67441680, 8462730, 361220, 2216738864, 587333888, 4219272, 873463873}); + auto d_expected = cudf::column_device_view::create(expected); + + // Check + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output, d_expected); +} +*/ \ No newline at end of file From fb55c3f0f689f7ba9084506acb060072c320be56 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 05:00:27 +0000 Subject: [PATCH 27/82] Major cleanups --- cpp/CMakeLists.txt | 1 + cpp/src/io/parquet/bloom_filter_reader.cu | 739 ++++++++++++--------- cpp/src/io/parquet/predicate_pushdown.cpp | 9 +- cpp/src/io/parquet/reader_impl_helpers.hpp | 38 +- 4 files changed, 442 insertions(+), 345 deletions(-) diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index e4fa3b28383..cc14489654c 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -502,6 +502,7 @@ add_library( src/datetime/timezone.cpp src/io/orc/writer_impl.cu src/io/parquet/arrow_schema_writer.cpp + src/io/parquet/bloom_filter_reader.cu src/io/parquet/compact_protocol_reader.cpp src/io/parquet/compact_protocol_writer.cpp src/io/parquet/decode_preprocess.cu diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 6eed5c5dd21..ecf59238a49 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -17,12 +17,14 @@ #include "arrow_filter_policy.cuh" #include "compact_protocol_reader.hpp" #include "io/parquet/parquet.hpp" +#include "io/parquet/parquet_gpu.hpp" #include "reader_impl_helpers.hpp" #include #include #include #include +#include #include #include #include @@ -32,7 +34,6 @@ #include #include -#include #include #include #include @@ -45,291 +46,95 @@ #include namespace cudf::io::parquet::detail { - namespace { -std::pair, std::vector> generate_chars_and_offsets(size_t num_keys) -{ - static std::vector const strings{"first", - "second", - "third", - "fourth", - "fifth", - "sixth", - "seventh", - "eighth", - "ninth", - "tenth", - "eleventh", - "twelfth", - "thirteenth", - "fourteenth", - "fifteenth", - "sixteenth", - "seventeenth", - "eighteenth"}; - - auto constexpr seed = 0xf00d; - /*static*/ std::mt19937 engine{seed}; - /*static*/ std::uniform_int_distribution dist{}; - - std::vector offsets(num_keys + 1); - std::vector chars; - chars.reserve(12 * num_keys); // 12 is the length of "seventeenth", the largest string - size_t offset = 0; - offsets[0] = size_t{0}; - std::generate_n(offsets.begin() + 1, num_keys, [&]() { - auto const& string = strings[dist(engine) % strings.size()]; - auto const char_ptr = const_cast(string.data()); - chars.insert(chars.end(), char_ptr, char_ptr + string.length()); - offset += string.length(); - return offset; - }); - return {std::move(chars), std::move(offsets)}; -} - -void validate_filter_bitwise(rmm::cuda_stream_view stream) -{ - std::size_t constexpr num_filter_blocks = 4; - std::size_t constexpr num_keys = 50; - - // Generate strings data - auto const [h_chars, h_offsets] = generate_chars_and_offsets(num_keys); - auto const mr = cudf::get_current_device_resource_ref(); - auto d_chars = cudf::detail::make_device_uvector_async(h_chars, stream, mr); - auto d_offsets = cudf::detail::make_device_uvector_async(h_offsets, stream, mr); - - rmm::device_uvector d_keys(num_keys, stream); - thrust::transform(rmm::exec_policy_nosync(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(num_keys), - d_keys.begin(), - [chars = thrust::raw_pointer_cast(d_chars.data()), - offsets = thrust::raw_pointer_cast(d_offsets.data())] __device__(auto idx) { - return cudf::string_view{ - chars + offsets[idx], - static_cast(offsets[idx + 1] - offsets[idx])}; - }); - - using key_type = cudf::string_view; - using hasher_type = cudf::hashing::detail::XXHash_64; - using policy_type = cuco::arrow_filter_policy; - using filter_type = cuco::bloom_filter, - cuda::thread_scope_device, - policy_type, - cudf::detail::cuco_allocator>; - // Spawn a bloom filter - filter_type filter{ - num_filter_blocks, - cuco::thread_scope_device, - {hasher_type{0}}, - cudf::detail::cuco_allocator{rmm::mr::polymorphic_allocator{}, stream}, - stream}; - - // Add strings to the bloom filter - filter.add(d_keys.begin(), d_keys.end(), stream); - - using word_type = filter_type::word_type; - - // Number of words in the filter - size_t const num_words = filter.block_extent() * filter.words_per_block; - - // Get the filter bitset words - cudf::device_span filter_bitset(filter.data(), num_words); - // Expected filter bitset words - rmm::device_vector const expected_bitset{ - 4194306, 4194305, 2359296, 1073774592, 524544, 1024, 268443648, 8519680, - 2147500040, 8421380, 269500416, 4202624, 8396802, 100665344, 2147747840, 5243136, - 131146, 655364, 285345792, 134222340, 545390596, 2281717768, 51201, 41943553, - 1619656708, 67441680, 8462730, 361220, 2216738864, 587333888, 4219272, 873463873}; - - CUDF_EXPECTS(thrust::equal(rmm::exec_policy(stream), - expected_bitset.cbegin(), - expected_bitset.cend(), - filter_bitset.begin()), - "Bloom filter bitset mismatched"); -} - /** - * @brief Temporary function that tests for key `third-037493` in bloom filters of column `c2` in - * test Parquet file. - * - * @tparam Key Type of the key to query bloom filter. - * - * @param buffer Device buffer containing bloom filter bitset - * @param chunk Current row group index - * @param stream CUDA stream used for device memory operations and kernel launches - * - */ -template -[[nodiscard]] bool check_arbitrary_string_key(rmm::device_buffer const& buffer, - Key const& literal, - size_t chunk_idx, - rmm::cuda_stream_view stream) -{ - using key_type = Key; - using hasher_type = cudf::hashing::detail::XXHash_64; - using policy_type = cuco::arrow_filter_policy; - using word_type = typename policy_type::word_type; - - // Filter properties - auto constexpr word_size = sizeof(word_type); - auto constexpr words_per_block = policy_type::words_per_block; - auto const num_filter_blocks = buffer.size() / (word_size * words_per_block); - - // Bitset ptr must be a non-const to be used in bloom_filter_ref. - auto bitset = const_cast(reinterpret_cast(buffer.data())); - - // Create a bloom filter view - cuco:: - bloom_filter_ref, cuco::thread_scope_device, policy_type> - filter{bitset, - num_filter_blocks, - {}, // scope - {hasher_type{0}}}; - - // Copy over the key (literal) to device - rmm::device_buffer d_key{literal.data(), static_cast(literal.size_bytes()), stream}; - - // Query literal in bloom filter. - bool const is_literal_found = - thrust::count_if(rmm::exec_policy(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(1), - [filter = filter, - key_ptr = reinterpret_cast(d_key.data()), - key_size = static_cast(d_key.size())] __device__(auto idx) { - auto const d_key = cudf::string_view{key_ptr, key_size}; - return filter.contains(d_key); - }); - - // MH: Temporary - auto const found_string = (is_literal_found) ? "YES" : "NO"; - std::cout << "Literal: third-037493 found in chunk " << chunk_idx << ": " << found_string - << std::endl; - - return is_literal_found; -} - -/** - * @brief Asynchronously reads bloom filters to device. - * - * @param sources Dataset sources - * @param num_chunks Number of total column chunks to read - * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk - * @param bloom_filter_offsets Bloom filter offsets for all chunks - * @param bloom_filter_sizes Bloom filter sizes for all chunks - * @param chunk_source_map Association between each column chunk and its source - * @param stream CUDA stream used for device memory operations and kernel launches + * @brief Converts bloom filter query results for column chunks to a device column. * - * @return A future object for reading synchronization */ -std::future read_bloom_filters_async( - host_span const> sources, - size_t num_chunks, - cudf::host_span bloom_filter_data, - cudf::host_span> bloom_filter_offsets, - cudf::host_span> bloom_filter_sizes, - std::vector const& chunk_source_map, - rmm::cuda_stream_view stream) -{ - // Read tasks for bloom filter data - std::vector> read_tasks; - - // Read bloom filters for all column chunks - std::for_each( - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(num_chunks), - [&](auto const chunk) { - // Read bloom filter if present - if (bloom_filter_offsets[chunk].has_value()) { - auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); - // If Bloom filter size (header + bitset) is available, just read the entire thing. - // Else just read 256 bytes which will contain the entire header and may contain the - // entire bitset as well. - auto constexpr bloom_filter_header_size_guess = 256; - auto const initial_read_size = - bloom_filter_sizes[chunk].value_or(bloom_filter_header_size_guess); - - // Read an initial buffer from source - auto& source = sources[chunk_source_map[chunk]]; - auto buffer = source->host_read(bloom_filter_offset, initial_read_size); - - // Deserialize the Bloom filter header from the buffer. - BloomFilterHeader header; - CompactProtocolReader cp{buffer->data(), buffer->size()}; - cp.read(&header); - - // Test if header is valid. - auto const is_header_valid = - (header.num_bytes % 32) == 0 and - header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and - header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and - header.hash.hash == BloomFilterHash::Hash::XXHASH; - - // Do not read if the bloom filter is invalid - if (not is_header_valid) { - CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); - return; - } - - // Bloom filter header size - auto const bloom_filter_header_size = static_cast(cp.bytecount()); - auto const bitset_size = header.num_bytes; - - // Check if we already read in the filter bitset in the initial read. - if (initial_read_size >= bloom_filter_header_size + bitset_size) { - bloom_filter_data[chunk] = - rmm::device_buffer(buffer->data() + bloom_filter_header_size, bitset_size, stream); - - // MH: TODO: Temporary test. Remove me!! - cudf::string_view literal{"third-037493", 12}; - std::ignore = - check_arbitrary_string_key(bloom_filter_data[chunk], literal, chunk, stream); - } - // Read the bitset from datasource. - else { - auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; - // Directly read to device if preferred - if (source->is_device_read_preferred(bitset_size)) { - bloom_filter_data[chunk] = rmm::device_buffer(bitset_size, stream); - auto future_read_size = - source->device_read_async(bitset_offset, - bitset_size, - static_cast(bloom_filter_data[chunk].data()), - stream); - - read_tasks.emplace_back(std::move(future_read_size)); - } else { - buffer = source->host_read(bitset_offset, bitset_size); - bloom_filter_data[chunk] = rmm::device_buffer(buffer->data(), buffer->size(), stream); - } - } - } - }); - - auto sync_fn = [](decltype(read_tasks) read_tasks) { - for (auto& task : read_tasks) { - task.wait(); +struct bloom_filter_caster { + size_t num_row_groups; + size_t num_equality_columns; + + // Creates device columns from column statistics (min, max) + template + std::unique_ptr operator()(cudf::device_span buffer_ptrs, + cudf::device_span buffer_sizes, + cudf::size_type equality_col_idx, + cudf::data_type dtype, + ast::literal* const& literal, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) const + { + // List, Struct, Dictionary types are not supported + if constexpr (cudf::is_compound() && !std::is_same_v) { + CUDF_FAIL("Compound types don't support equality predicate"); + } else { + CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), "Mismatched data_types"); + + using key_type = T; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + using word_type = typename policy_type::word_type; + + // Filter properties + auto constexpr word_size = sizeof(word_type); + auto constexpr words_per_block = policy_type::words_per_block; + + rmm::device_buffer results{num_row_groups, stream, mr}; + + // Query literal in bloom filters. + thrust::for_each( + rmm::exec_policy(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(num_row_groups), + [buffer_ptrs = buffer_ptrs.data(), + buffer_sizes = buffer_sizes.data(), + d_scalar = literal->get_value(), + col_idx = equality_col_idx, + num_equality_columns = num_equality_columns, + results = reinterpret_cast(results.data())] __device__(auto row_group_idx) { + // Filter bitset buffer index + auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); + // Bitset ptr must be a non-const to be used in bloom_filter_ref. + auto bitset_ptr = reinterpret_cast(buffer_ptrs[filter_idx]); + auto const num_filter_blocks = buffer_sizes[filter_idx] / (word_size * words_per_block); + + // Create a bloom filter view + cuco::bloom_filter_ref, + cuco::thread_scope_device, + policy_type> + filter{bitset_ptr, + num_filter_blocks, + {}, // scope + {hasher_type{0}}}; + + // Query the bloom filter and store results + results[row_group_idx] = filter.contains(d_scalar.value()); + }); + + return std::make_unique(cudf::data_type{cudf::type_id::BOOL8}, + static_cast(num_row_groups), + std::move(results), + rmm::device_buffer{}, + 0); } - }; - - // MH: Remove me. Bitwise validation for bloom filter - validate_filter_bitwise(stream); - - return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); -} + } +}; /** - * @brief Collects column indices with an equality predicate in the AST expression. - * This is used in row group filtering based on bloom filters. + * @brief Collects lists of equality predicate literals in the AST expression, one list per input + * table column. This is used in row group filtering based on bloom filters. */ -class equality_predicate_converter : public ast::detail::expression_transformer { +class equality_literals_collector : public ast::detail::expression_transformer { public: - equality_predicate_converter(ast::expression const& expr, size_type const& num_columns) + equality_literals_collector() = default; + + equality_literals_collector(ast::expression const& expr, cudf::size_type num_columns) : _num_columns{num_columns} { + _equality_literals.resize(_num_columns); expr.accept(*this); } @@ -380,7 +185,13 @@ class equality_predicate_converter : public ast::detail::expression_transformer CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, "Second operand of binary operation with column reference must be a literal"); v->accept(*this); - if (op == ast_operator::EQUAL) { equality_col_idx.emplace_back(v->get_column_index()); } + + // Push to the corresponding column's literals list if equality predicate seen + if (op == ast_operator::EQUAL) { + auto const col_idx = v->get_column_index(); + _equality_literals[col_idx].emplace_back( + const_cast(dynamic_cast(&operands[1].get()))); + } } else { auto new_operands = visit_operands(operands); if (cudf::ast::detail::ast_operator_arity(op) == 2) { @@ -394,16 +205,16 @@ class equality_predicate_converter : public ast::detail::expression_transformer } /** - * @brief Returns a list of column indices with an equality predicate in the AST expression. + * @brief Vectors of equality literals in the AST expression, one per input table column * - * @return List of column indices + * @return Vectors of equality literals, one per input table column */ - [[nodiscard]] std::vector get_equality_col_idx() const + [[nodiscard]] std::vector> get_equality_literals() const { - return equality_col_idx; + return _equality_literals; } - private: + protected: std::vector> visit_operands( cudf::host_span const> operands) { @@ -415,67 +226,218 @@ class equality_predicate_converter : public ast::detail::expression_transformer return transformed_operands; } std::optional> _equality_expr; - std::vector equality_col_idx; - size_type _num_columns; + std::vector> _equality_literals; std::list _col_ref; std::list _operators; + size_type _num_columns; }; -} // namespace +/** + * @brief Collects column indices with an equality predicate in the AST expression. + * This is used in row group filtering based on bloom filters. + */ +class equality_predicate_evaluator : public equality_literals_collector { + public: + equality_predicate_evaluator(ast::expression const& expr, + size_type num_columns, + std::vector> const& equality_literals) + { + // Set the num columns and equality literals + _num_columns = num_columns; + _equality_literals = std::move(equality_literals); + + // Compute and store columns literals offsets + _col_literals_offsets.reserve(_num_columns + 1); + _col_literals_offsets.emplace_back(0); + + std::transform(equality_literals.begin(), + equality_literals.end(), + std::back_inserter(_col_literals_offsets), + [&](auto const& col_literal_map) { + return _col_literals_offsets.back() + + static_cast(col_literal_map.size()); + }); + + // Add this visitor + expr.accept(*this); + } -std::optional>> aggregate_reader_metadata::apply_bloom_filters( + /** + * @brief Delete equality literals getter + */ + [[nodiscard]] std::vector> get_equality_literals() = delete; + + // Bring all overloads of `visit` from equality_predicate_collector into scope + using equality_literals_collector::visit; + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + */ + std::reference_wrapper visit(ast::operation const& expr) override + { + using cudf::ast::ast_operator; + auto const operands = expr.get_operands(); + auto const op = expr.get_operator(); + + if (auto* v = dynamic_cast(&operands[0].get())) { + // First operand should be column reference, second should be literal. + CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, + "Only binary operations are supported on column reference"); + CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, + "Second operand of binary operation with column reference must be a literal"); + v->accept(*this); + + if (op == ast_operator::EQUAL) { + auto const literal_ptr = + const_cast(dynamic_cast(&operands[1].get())); + auto const col_idx = v->get_column_index(); + auto const& equality_literals = _equality_literals[col_idx]; + auto col_ref_offset = _col_literals_offsets[col_idx]; + auto const ptr = + std::find(equality_literals.cbegin(), equality_literals.cend(), literal_ptr); + CUDF_EXPECTS(ptr != equality_literals.end(), "Could not find the literal ptr"); + col_ref_offset += std::distance(equality_literals.cbegin(), ptr); + + auto const& value = _col_ref.emplace_back(col_ref_offset); + auto const& op = _operators.emplace_back(ast_operator::NOT, value); + _operators.emplace_back(ast_operator::NOT, op); + } + } else { + auto new_operands = visit_operands(operands); + if (cudf::ast::detail::ast_operator_arity(op) == 2) { + _operators.emplace_back(op, new_operands.front(), new_operands.back()); + } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { + _operators.emplace_back(op, new_operands.front()); + } + } + _equality_expr = std::reference_wrapper(_operators.back()); + return std::reference_wrapper(_operators.back()); + } + + /** + * @brief Returns the AST to apply on bloom filters + * + * @return AST operation expression + */ + [[nodiscard]] std::reference_wrapper get_equality_expr() const + { + return _equality_expr.value().get(); + } + + private: + std::vector _col_literals_offsets; +}; + +/** + * @brief Asynchronously reads bloom filters to device. + * + * @param sources Dataset sources + * @param num_chunks Number of total column chunks to read + * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param bloom_filter_offsets Bloom filter offsets for all chunks + * @param bloom_filter_sizes Bloom filter sizes for all chunks + * @param chunk_source_map Association between each column chunk and its source + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return A future object for reading synchronization + */ +std::future read_bloom_filters_async( host_span const> sources, - host_span const> row_group_indices, - host_span output_dtypes, - host_span output_column_schemas, - std::reference_wrapper filter, - rmm::cuda_stream_view stream) const + size_t num_chunks, + cudf::host_span bloom_filter_data, + cudf::host_span> bloom_filter_offsets, + cudf::host_span> bloom_filter_sizes, + std::vector const& chunk_source_map, + rmm::cuda_stream_view stream) { - auto const num_cols = output_dtypes.size(); - CUDF_EXPECTS(output_dtypes.size() == output_column_schemas.size(), - "Mismatched size between lists of output column dtypes and output column schema"); - auto mr = cudf::get_current_device_resource_ref(); - std::vector> cols; - // MH: How do I test for nested or non-comparable columns here? - cols.emplace_back(cudf::make_numeric_column( - data_type{cudf::type_id::INT32}, num_cols, rmm::device_buffer{}, 0, stream, mr)); + // Read tasks for bloom filter data + std::vector> read_tasks; - auto mutable_col_idx = cols.back()->mutable_view(); + // Read bloom filters for all column chunks + std::for_each( + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(num_chunks), + [&](auto const chunk) { + // Read bloom filter if present + if (bloom_filter_offsets[chunk].has_value()) { + auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); + // If Bloom filter size (header + bitset) is available, just read the entire thing. + // Else just read 256 bytes which will contain the entire header and may contain the + // entire bitset as well. + auto constexpr bloom_filter_size_guess = 256; + auto const initial_read_size = + static_cast(bloom_filter_sizes[chunk].value_or(bloom_filter_size_guess)); - thrust::sequence(rmm::exec_policy(stream), - mutable_col_idx.begin(), - mutable_col_idx.end(), - 0); + // Read an initial buffer from source + auto& source = sources[chunk_source_map[chunk]]; + auto buffer = source->host_read(bloom_filter_offset, initial_read_size); - auto equality_table = cudf::table(std::move(cols)); + // Deserialize the Bloom filter header from the buffer. + BloomFilterHeader header; + CompactProtocolReader cp{buffer->data(), buffer->size()}; + cp.read(&header); - // Converts AST to EqualityAST with reference to min, max columns in above `stats_table`. - equality_predicate_converter equality_expr{filter.get(), static_cast(num_cols)}; - auto equality_col_schemas = equality_expr.get_equality_col_idx(); + // Test if header is valid. + auto const is_header_valid = + (header.num_bytes % 32) == 0 and + header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and + header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and + header.hash.hash == BloomFilterHash::Hash::XXHASH; - // Convert column indices to column schema indices - std::for_each(equality_col_schemas.begin(), equality_col_schemas.end(), [&](auto& col_idx) { - col_idx = output_column_schemas[col_idx]; - }); + // Do not read if the bloom filter is invalid + if (not is_header_valid) { + CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); + return; + } - std::ignore = read_bloom_filters(sources, row_group_indices, equality_col_schemas, stream); + // Bloom filter header size + auto const bloom_filter_header_size = static_cast(cp.bytecount()); + size_t const bitset_size = header.num_bytes; - return std::nullopt; + // Check if we already read in the filter bitset in the initial read. + if (initial_read_size >= bloom_filter_header_size + bitset_size) { + bloom_filter_data[chunk] = + rmm::device_buffer{buffer->data() + bloom_filter_header_size, bitset_size, stream}; + } + // Read the bitset from datasource. + else { + auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; + // Directly read to device if preferred + if (source->is_device_read_preferred(bitset_size)) { + bloom_filter_data[chunk] = rmm::device_buffer{bitset_size, stream}; + auto future_read_size = + source->device_read_async(bitset_offset, + bitset_size, + static_cast(bloom_filter_data[chunk].data()), + stream); + + read_tasks.emplace_back(std::move(future_read_size)); + } else { + buffer = source->host_read(bitset_offset, bitset_size); + bloom_filter_data[chunk] = rmm::device_buffer{buffer->data(), buffer->size(), stream}; + } + } + } + }); + + auto sync_fn = [](decltype(read_tasks) read_tasks) { + for (auto& task : read_tasks) { + task.wait(); + } + }; + + return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); } +} // namespace + std::vector aggregate_reader_metadata::read_bloom_filters( host_span const> sources, host_span const> row_group_indices, host_span column_schemas, + size_type num_row_groups, rmm::cuda_stream_view stream) const { - // Number of total row groups to process. - auto const num_row_groups = std::accumulate( - row_group_indices.begin(), - row_group_indices.end(), - size_t{0}, - [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); - // Descriptors for all the chunks that make up the selected columns auto const num_input_columns = column_schemas.size(); auto const num_chunks = num_row_groups * num_input_columns; @@ -518,7 +480,7 @@ std::vector aggregate_reader_metadata::read_bloom_filters( if (std::any_of(bloom_filter_offsets.cbegin(), bloom_filter_offsets.cend(), [](auto const offset) { return offset.has_value(); })) { - // Create a vector to store bloom filter data + // Vector to hold bloom filter data std::vector bloom_filter_data(num_chunks); // Wait on bloom filter read tasks @@ -530,10 +492,145 @@ std::vector aggregate_reader_metadata::read_bloom_filters( chunk_source_map, stream) .wait(); - // Return the vector + + // Return bloom filter data return bloom_filter_data; } + + // Return empty vector return {}; } +std::optional>> aggregate_reader_metadata::apply_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + std::reference_wrapper filter, + rmm::cuda_stream_view stream) const +{ + auto const num_cols = static_cast(output_dtypes.size()); + CUDF_EXPECTS(output_dtypes.size() == output_column_schemas.size(), + "Mismatched size between lists of output column dtypes and output column schema"); + auto mr = cudf::get_current_device_resource_ref(); + + // Number of total row groups to process. + auto const num_row_groups = std::accumulate( + row_group_indices.begin(), + row_group_indices.end(), + size_t{0}, + [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); + + // Collect equality literals for each input table column + auto const equality_literals = + equality_literals_collector{filter.get(), num_cols}.get_equality_literals(); + + std::vector equality_col_schemas; + // Convert column indices to column schema indices + std::for_each(thrust::make_counting_iterator(0), + thrust::make_counting_iterator(output_column_schemas.size()), + [&](auto col_idx) { + // Only for columns that have a non-empty list of literals associated with it + if (equality_literals[col_idx].size()) { + equality_col_schemas.emplace_back(output_column_schemas[col_idx]); + } + }); + + // Return early if no equality column + if (equality_col_schemas.empty()) { return {}; } + + auto bloom_filter_data = + read_bloom_filters(sources, row_group_indices, equality_col_schemas, num_row_groups, stream); + + // No bloom filter buffers, return the original row group indices + if (not bloom_filter_data.size()) { return {}; } + + // Copy bitset buffer pointers and sizes to device for querying + std::vector h_buffer_ptrs(bloom_filter_data.size()); + std::vector h_buffer_sizes(bloom_filter_data.size()); + std::for_each(thrust::make_counting_iterator(0), + thrust::make_counting_iterator(bloom_filter_data.size()), + [&](auto i) { + auto const& buffer = bloom_filter_data[i]; + h_buffer_ptrs[i] = const_cast(buffer.data()); + h_buffer_sizes[i] = buffer.size(); + }); + + auto buffer_ptrs = cudf::detail::make_device_uvector_async(h_buffer_ptrs, stream, mr); + auto buffer_sizes = cudf::detail::make_device_uvector_async(h_buffer_sizes, stream, mr); + + // Create a bloom filter table caster + bloom_filter_caster bloom_filter_col{num_row_groups, equality_col_schemas.size()}; + + // Create a table + std::vector> columns; + + size_t idx = 0; + for (size_t col_idx = 0; col_idx < output_dtypes.size(); col_idx++) { + if (equality_literals[col_idx].empty()) { continue; } + auto const& dtype = output_dtypes[col_idx]; + + // Only comparable types except fixed point are supported. + if (cudf::is_compound(dtype) and dtype.id() != cudf::type_id::STRING) { continue; } + + auto& literals = equality_literals[col_idx]; + for (ast::literal* const& literal : literals) { + columns.push_back(cudf::type_dispatcher( + dtype, bloom_filter_col, buffer_ptrs, buffer_sizes, idx, dtype, literal, stream, mr)); + } + idx++; + } + + auto equality_table = cudf::table(std::move(columns)); + + // Make another expression converter but provide the literal map. + equality_predicate_evaluator equality_expr{filter.get(), num_cols, equality_literals}; + + auto equality_ast = equality_expr.get_equality_expr(); + auto predicate_col = cudf::detail::compute_column(equality_table, equality_ast.get(), stream, mr); + auto predicate = predicate_col->view(); + CUDF_EXPECTS(predicate.type().id() == cudf::type_id::BOOL8, + "Filter expression must return a boolean column"); + + auto const host_bitmask = [&] { + auto const num_bitmasks = num_bitmask_words(predicate.size()); + if (predicate.nullable()) { + return cudf::detail::make_host_vector_sync( + device_span(predicate.null_mask(), num_bitmasks), stream); + } else { + auto bitmask = cudf::detail::make_host_vector(num_bitmasks, stream); + std::fill(bitmask.begin(), bitmask.end(), ~bitmask_type{0}); + return bitmask; + } + }(); + + auto validity_it = cudf::detail::make_counting_transform_iterator( + 0, [bitmask = host_bitmask.data()](auto bit_index) { return bit_is_set(bitmask, bit_index); }); + + auto const is_row_group_required = cudf::detail::make_host_vector_sync( + device_span(predicate.data(), predicate.size()), stream); + + // Return only filtered row groups based on predicate + // if all are required or all are nulls, return. + if (predicate.null_count() == predicate.size() or std::all_of(is_row_group_required.cbegin(), + is_row_group_required.cend(), + [](auto i) { return bool(i); })) { + return std::nullopt; + } + size_type is_required_idx = 0; + std::vector> filtered_row_group_indices; + for (auto const& input_row_group_index : row_group_indices) { + std::vector filtered_row_groups; + for (auto const rg_idx : input_row_group_index) { + if ((!validity_it[is_required_idx]) || is_row_group_required[is_required_idx]) { + filtered_row_groups.push_back(rg_idx); + } + ++is_required_idx; + } + filtered_row_group_indices.push_back(std::move(filtered_row_groups)); + } + + return {filtered_row_group_indices}; +} + } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index eb30cc6955a..2c6eea37c13 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -475,10 +475,9 @@ std::optional>> aggregate_reader_metadata::fi // Return only filtered row groups based on predicate // if all are required or all are nulls, return. - if (std::all_of(is_row_group_required.cbegin(), - is_row_group_required.cend(), - [](auto i) { return bool(i); }) or - predicate.null_count() == predicate.size()) { + if (predicate.null_count() == predicate.size() or std::all_of(is_row_group_required.cbegin(), + is_row_group_required.cend(), + [](auto i) { return bool(i); })) { // Call with input_row_group_indices return apply_bloom_filters( sources, input_row_group_indices, output_dtypes, output_column_schemas, filter, stream); @@ -498,8 +497,6 @@ std::optional>> aggregate_reader_metadata::fi // Call with filtered_row_group_indices return apply_bloom_filters( sources, filtered_row_group_indices, output_dtypes, output_column_schemas, filter, stream); - - return {std::move(filtered_row_group_indices)}; } // convert column named expression to column index reference expression diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 75edfdb7f7b..4a30b8b4593 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -195,6 +195,26 @@ class aggregate_reader_metadata { */ void column_info_for_row_group(row_group_info& rg_info, size_type chunk_start_row) const; + /** + * @brief Reads bloom filter bitsets for the specified columns from the given lists of row + * groups. + * + * @param sources Dataset sources + * @param row_group_indices Lists of row groups to read bloom filters from, one per source + * @param[out] bloom_filter_data List of bloom filter data device buffers + * @param column_schemas Schema indices of columns whose bloom filters will be read + * @param stream CUDA stream used for device memory operations and kernel launches + * + * @return A flattened list of bloom filter bitset device buffers for each equality column in each + * row group + */ + [[nodiscard]] std::vector read_bloom_filters( + host_span const> sources, + host_span const> row_group_indices, + host_span column_schemas, + size_type num_row_groups, + rmm::cuda_stream_view stream) const; + public: aggregate_reader_metadata(host_span const> sources, bool use_arrow_schema, @@ -386,24 +406,6 @@ class aggregate_reader_metadata { std::optional> filter, rmm::cuda_stream_view stream) const; - /** - * @brief Reads bloom filter bitsets for the specified columns from the given lists of row - * groups. - * - * @param sources Dataset sources - * @param row_group_indices Lists of row groups to read bloom filters from, one per source - * @param column_schemas Schema indices of columns whose bloom filters will be read - * @param stream CUDA stream used for device memory operations and kernel launches - * - * @return A list of bloom filter bitset device buffers flattened over column schemas over lists - * of row group indices - */ - std::vector read_bloom_filters( - host_span const> sources, - host_span const> row_group_indices, - host_span column_schemas, - rmm::cuda_stream_view stream) const; - /** * @brief Filters and reduces down to a selection of columns * From b477d2d025924a213f8d64aa028194808da91fde Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 07:10:58 +0000 Subject: [PATCH 28/82] Significant code refactoring --- cpp/src/io/parquet/bloom_filter_reader.cu | 212 +++++++++------------ cpp/src/io/parquet/predicate_pushdown.cpp | 114 ++++++----- cpp/src/io/parquet/reader_impl_helpers.hpp | 21 +- 3 files changed, 181 insertions(+), 166 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index ecf59238a49..5866c42075c 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -53,30 +53,31 @@ namespace { * */ struct bloom_filter_caster { + cudf::device_span buffer_ptrs; + cudf::device_span buffer_sizes; size_t num_row_groups; size_t num_equality_columns; - // Creates device columns from column statistics (min, max) + // Creates device columns from bloom filter membership template - std::unique_ptr operator()(cudf::device_span buffer_ptrs, - cudf::device_span buffer_sizes, - cudf::size_type equality_col_idx, + std::unique_ptr operator()(cudf::size_type equality_col_idx, cudf::data_type dtype, ast::literal* const& literal, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) const { // List, Struct, Dictionary types are not supported - if constexpr (cudf::is_compound() && !std::is_same_v) { + if constexpr (cudf::is_compound() and not std::is_same_v) { CUDF_FAIL("Compound types don't support equality predicate"); } else { - CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), "Mismatched data_types"); - using key_type = T; using hasher_type = cudf::hashing::detail::XXHash_64; using policy_type = cuco::arrow_filter_policy; using word_type = typename policy_type::word_type; + // Check if the literal has the same type as the column + CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), "Mismatched data_types"); + // Filter properties auto constexpr word_size = sizeof(word_type); auto constexpr words_per_block = policy_type::words_per_block; @@ -85,7 +86,7 @@ struct bloom_filter_caster { // Query literal in bloom filters. thrust::for_each( - rmm::exec_policy(stream), + rmm::exec_policy_nosync(stream), thrust::make_counting_iterator(0), thrust::make_counting_iterator(num_row_groups), [buffer_ptrs = buffer_ptrs.data(), @@ -95,20 +96,19 @@ struct bloom_filter_caster { num_equality_columns = num_equality_columns, results = reinterpret_cast(results.data())] __device__(auto row_group_idx) { // Filter bitset buffer index - auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); - // Bitset ptr must be a non-const to be used in bloom_filter_ref. - auto bitset_ptr = reinterpret_cast(buffer_ptrs[filter_idx]); + auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); auto const num_filter_blocks = buffer_sizes[filter_idx] / (word_size * words_per_block); - // Create a bloom filter view + // Create a bloom filter view. cuco::bloom_filter_ref, - cuco::thread_scope_device, + cuco::thread_scope_thread, policy_type> - filter{bitset_ptr, + filter{reinterpret_cast(buffer_ptrs[filter_idx]), num_filter_blocks, - {}, // scope - {hasher_type{0}}}; + {}, // thread scope since the same literal is being search across different + // bitsets per thread + {}}; // Arrow policy with default xxhash_64, seed = 0 for Arrow compatiblity // Query the bloom filter and store results results[row_group_idx] = filter.contains(d_scalar.value()); @@ -131,10 +131,10 @@ class equality_literals_collector : public ast::detail::expression_transformer { public: equality_literals_collector() = default; - equality_literals_collector(ast::expression const& expr, cudf::size_type num_columns) - : _num_columns{num_columns} + equality_literals_collector(ast::expression const& expr, cudf::size_type num_input_columns) + : _num_input_columns{num_input_columns} { - _equality_literals.resize(_num_columns); + _equality_literals.resize(_num_input_columns); expr.accept(*this); } @@ -154,7 +154,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { { CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, "Equality AST supports only left table"); - CUDF_EXPECTS(expr.get_column_index() < _num_columns, + CUDF_EXPECTS(expr.get_column_index() < _num_input_columns, "Column index cannot be more than number of columns in the table"); _equality_expr = std::reference_wrapper(expr); return expr; @@ -229,25 +229,26 @@ class equality_literals_collector : public ast::detail::expression_transformer { std::vector> _equality_literals; std::list _col_ref; std::list _operators; - size_type _num_columns; + size_type _num_input_columns; }; /** - * @brief Collects column indices with an equality predicate in the AST expression. - * This is used in row group filtering based on bloom filters. + * @brief Converts AST expression to bloom filter membership (BloomfilterAST) expression . + * This is used in row group filtering based on equality predicate. */ -class equality_predicate_evaluator : public equality_literals_collector { +class bloom_filter_expression_converter : public equality_literals_collector { public: - equality_predicate_evaluator(ast::expression const& expr, - size_type num_columns, - std::vector> const& equality_literals) + bloom_filter_expression_converter( + ast::expression const& expr, + size_type num_input_columns, + std::vector> const& equality_literals) { // Set the num columns and equality literals - _num_columns = num_columns; + _num_input_columns = num_input_columns; _equality_literals = std::move(equality_literals); // Compute and store columns literals offsets - _col_literals_offsets.reserve(_num_columns + 1); + _col_literals_offsets.reserve(_num_input_columns + 1); _col_literals_offsets.emplace_back(0); std::transform(equality_literals.begin(), @@ -263,7 +264,7 @@ class equality_predicate_evaluator : public equality_literals_collector { } /** - * @brief Delete equality literals getter + * @brief Delete equality literals getter as no longer needed */ [[nodiscard]] std::vector> get_equality_literals() = delete; @@ -329,7 +330,7 @@ class equality_predicate_evaluator : public equality_literals_collector { }; /** - * @brief Asynchronously reads bloom filters to device. + * @brief Asynchronously reads bloom filters data to device. * * @param sources Dataset sources * @param num_chunks Number of total column chunks to read @@ -341,7 +342,7 @@ class equality_predicate_evaluator : public equality_literals_collector { * * @return A future object for reading synchronization */ -std::future read_bloom_filters_async( +std::future read_bloom_filter_data_async( host_span const> sources, size_t num_chunks, cudf::host_span bloom_filter_data, @@ -483,14 +484,14 @@ std::vector aggregate_reader_metadata::read_bloom_filters( // Vector to hold bloom filter data std::vector bloom_filter_data(num_chunks); - // Wait on bloom filter read tasks - read_bloom_filters_async(sources, - num_chunks, - bloom_filter_data, - bloom_filter_offsets, - bloom_filter_sizes, - chunk_source_map, - stream) + // Wait on bloom filter data read tasks + read_bloom_filter_data_async(sources, + num_chunks, + bloom_filter_data, + bloom_filter_offsets, + bloom_filter_sizes, + chunk_source_map, + stream) .wait(); // Return bloom filter data @@ -509,12 +510,12 @@ std::optional>> aggregate_reader_metadata::ap std::reference_wrapper filter, rmm::cuda_stream_view stream) const { - auto const num_cols = static_cast(output_dtypes.size()); - CUDF_EXPECTS(output_dtypes.size() == output_column_schemas.size(), - "Mismatched size between lists of output column dtypes and output column schema"); + // Number of input table columns + auto const num_input_columns = static_cast(output_dtypes.size()); + auto mr = cudf::get_current_device_resource_ref(); - // Number of total row groups to process. + // Total number of row groups to process. auto const num_row_groups = std::accumulate( row_group_indices.begin(), row_group_indices.end(), @@ -523,10 +524,10 @@ std::optional>> aggregate_reader_metadata::ap // Collect equality literals for each input table column auto const equality_literals = - equality_literals_collector{filter.get(), num_cols}.get_equality_literals(); + equality_literals_collector{filter.get(), num_input_columns}.get_equality_literals(); + // Collect schema indices of columns with equality predicate(s) std::vector equality_col_schemas; - // Convert column indices to column schema indices std::for_each(thrust::make_counting_iterator(0), thrust::make_counting_iterator(output_column_schemas.size()), [&](auto col_idx) { @@ -536,101 +537,72 @@ std::optional>> aggregate_reader_metadata::ap } }); - // Return early if no equality column - if (equality_col_schemas.empty()) { return {}; } + // Return early if no column with equality predicate(s) + if (equality_col_schemas.empty()) { return std::nullopt; } + // Read a vector of bloom filter bitset device buffers for all column with equality predicate(s) + // across all row groups auto bloom_filter_data = read_bloom_filters(sources, row_group_indices, equality_col_schemas, num_row_groups, stream); // No bloom filter buffers, return the original row group indices - if (not bloom_filter_data.size()) { return {}; } + if (bloom_filter_data.empty()) { return std::nullopt; } - // Copy bitset buffer pointers and sizes to device for querying + // Copy bloom filter bitset buffer pointers and sizes to device std::vector h_buffer_ptrs(bloom_filter_data.size()); std::vector h_buffer_sizes(bloom_filter_data.size()); std::for_each(thrust::make_counting_iterator(0), thrust::make_counting_iterator(bloom_filter_data.size()), [&](auto i) { auto const& buffer = bloom_filter_data[i]; - h_buffer_ptrs[i] = const_cast(buffer.data()); - h_buffer_sizes[i] = buffer.size(); + // Bitset ptr must be non-const to be used in cuco::bloom_filter. + h_buffer_ptrs[i] = const_cast(buffer.data()); + h_buffer_sizes[i] = buffer.size(); }); - auto buffer_ptrs = cudf::detail::make_device_uvector_async(h_buffer_ptrs, stream, mr); auto buffer_sizes = cudf::detail::make_device_uvector_async(h_buffer_sizes, stream, mr); - // Create a bloom filter table caster - bloom_filter_caster bloom_filter_col{num_row_groups, equality_col_schemas.size()}; + // Create a bloom filter query table caster. + bloom_filter_caster bloom_filter_col{ + buffer_ptrs, buffer_sizes, num_row_groups, equality_col_schemas.size()}; - // Create a table + // Converts bloom filter membership for equality predicate columns to a table + // containing a column for each `col[i] == literal` predicate to be evaluated. + // The table contains #sources * #column_chunks_per_src rows. std::vector> columns; + size_t equality_col_idx = 0; + std::for_each(thrust::make_counting_iterator(0), + thrust::make_counting_iterator(output_dtypes.size()), + [&](auto input_col_idx) { + auto const& dtype = output_dtypes[input_col_idx]; - size_t idx = 0; - for (size_t col_idx = 0; col_idx < output_dtypes.size(); col_idx++) { - if (equality_literals[col_idx].empty()) { continue; } - auto const& dtype = output_dtypes[col_idx]; - - // Only comparable types except fixed point are supported. - if (cudf::is_compound(dtype) and dtype.id() != cudf::type_id::STRING) { continue; } - - auto& literals = equality_literals[col_idx]; - for (ast::literal* const& literal : literals) { - columns.push_back(cudf::type_dispatcher( - dtype, bloom_filter_col, buffer_ptrs, buffer_sizes, idx, dtype, literal, stream, mr)); - } - idx++; - } - - auto equality_table = cudf::table(std::move(columns)); - - // Make another expression converter but provide the literal map. - equality_predicate_evaluator equality_expr{filter.get(), num_cols, equality_literals}; - - auto equality_ast = equality_expr.get_equality_expr(); - auto predicate_col = cudf::detail::compute_column(equality_table, equality_ast.get(), stream, mr); - auto predicate = predicate_col->view(); - CUDF_EXPECTS(predicate.type().id() == cudf::type_id::BOOL8, - "Filter expression must return a boolean column"); - - auto const host_bitmask = [&] { - auto const num_bitmasks = num_bitmask_words(predicate.size()); - if (predicate.nullable()) { - return cudf::detail::make_host_vector_sync( - device_span(predicate.null_mask(), num_bitmasks), stream); - } else { - auto bitmask = cudf::detail::make_host_vector(num_bitmasks, stream); - std::fill(bitmask.begin(), bitmask.end(), ~bitmask_type{0}); - return bitmask; - } - }(); - - auto validity_it = cudf::detail::make_counting_transform_iterator( - 0, [bitmask = host_bitmask.data()](auto bit_index) { return bit_is_set(bitmask, bit_index); }); - - auto const is_row_group_required = cudf::detail::make_host_vector_sync( - device_span(predicate.data(), predicate.size()), stream); + // Skip if no equality literals for this column + if (equality_literals[input_col_idx].empty()) { return; } - // Return only filtered row groups based on predicate - // if all are required or all are nulls, return. - if (predicate.null_count() == predicate.size() or std::all_of(is_row_group_required.cbegin(), - is_row_group_required.cend(), - [](auto i) { return bool(i); })) { - return std::nullopt; - } - size_type is_required_idx = 0; - std::vector> filtered_row_group_indices; - for (auto const& input_row_group_index : row_group_indices) { - std::vector filtered_row_groups; - for (auto const rg_idx : input_row_group_index) { - if ((!validity_it[is_required_idx]) || is_row_group_required[is_required_idx]) { - filtered_row_groups.push_back(rg_idx); - } - ++is_required_idx; - } - filtered_row_group_indices.push_back(std::move(filtered_row_groups)); - } + // Skip if non-comparable (compound) type except string + if (cudf::is_compound(dtype) and dtype.id() != cudf::type_id::STRING) { return; } - return {filtered_row_group_indices}; + // Add a column for all literals associated with an equality column + for (ast::literal* const& literal : equality_literals[input_col_idx]) { + columns.emplace_back(cudf::type_dispatcher( + dtype, bloom_filter_col, equality_col_idx, dtype, literal, stream, mr)); + } + equality_col_idx++; + }); + auto bloom_filter_membership_table = cudf::table(std::move(columns)); + + // Convert AST to BloomfilterAST expression with reference to bloom filter membership + // in above `bloom_filter_membership_table` + bloom_filter_expression_converter equality_expr{ + filter.get(), num_input_columns, equality_literals}; + + // Filter bloom filter membership table with the BloomfilterAST expression and collect filtered + // row group indices + return collect_filtered_row_group_indices(bloom_filter_membership_table, + equality_expr.get_equality_expr(), + row_group_indices, + stream, + mr); } } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 2c6eea37c13..760b2d18eec 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -400,7 +400,6 @@ std::optional>> aggregate_reader_metadata::fi { auto mr = cudf::get_current_device_resource_ref(); // Create row group indices. - std::vector> filtered_row_group_indices; std::vector> all_row_group_indices; host_span const> input_row_group_indices; if (row_group_indices.empty()) { @@ -449,54 +448,24 @@ std::optional>> aggregate_reader_metadata::fi // Converts AST to StatsAST with reference to min, max columns in above `stats_table`. stats_expression_converter stats_expr{filter.get(), static_cast(output_dtypes.size())}; - auto stats_ast = stats_expr.get_stats_expr(); - auto predicate_col = cudf::detail::compute_column(stats_table, stats_ast.get(), stream, mr); - auto predicate = predicate_col->view(); - CUDF_EXPECTS(predicate.type().id() == cudf::type_id::BOOL8, - "Filter expression must return a boolean column"); - auto const host_bitmask = [&] { - auto const num_bitmasks = num_bitmask_words(predicate.size()); - if (predicate.nullable()) { - return cudf::detail::make_host_vector_sync( - device_span(predicate.null_mask(), num_bitmasks), stream); - } else { - auto bitmask = cudf::detail::make_host_vector(num_bitmasks, stream); - std::fill(bitmask.begin(), bitmask.end(), ~bitmask_type{0}); - return bitmask; - } - }(); + // Filter stats table with StatsAST expression and collect filtered row group indices + auto filtered_row_group_indices = collect_filtered_row_group_indices( + stats_table, stats_expr.get_stats_expr(), row_group_indices, stream, mr); - auto validity_it = cudf::detail::make_counting_transform_iterator( - 0, [bitmask = host_bitmask.data()](auto bit_index) { return bit_is_set(bitmask, bit_index); }); + // Span on row groups to apply bloom filtering on. + auto const bloom_filter_input_row_groups = + filtered_row_group_indices.has_value() + ? host_span const>(filtered_row_group_indices.value()) + : input_row_group_indices; - auto const is_row_group_required = cudf::detail::make_host_vector_sync( - device_span(predicate.data(), predicate.size()), stream); + // Apply bloom filtering on the bloom filter input row groups + auto const bloom_filtered_row_groups = apply_bloom_filters( + sources, bloom_filter_input_row_groups, output_dtypes, output_column_schemas, filter, stream); - // Return only filtered row groups based on predicate - // if all are required or all are nulls, return. - if (predicate.null_count() == predicate.size() or std::all_of(is_row_group_required.cbegin(), - is_row_group_required.cend(), - [](auto i) { return bool(i); })) { - // Call with input_row_group_indices - return apply_bloom_filters( - sources, input_row_group_indices, output_dtypes, output_column_schemas, filter, stream); - } - size_type is_required_idx = 0; - for (auto const& input_row_group_index : input_row_group_indices) { - std::vector filtered_row_groups; - for (auto const rg_idx : input_row_group_index) { - if ((!validity_it[is_required_idx]) || is_row_group_required[is_required_idx]) { - filtered_row_groups.push_back(rg_idx); - } - ++is_required_idx; - } - filtered_row_group_indices.push_back(std::move(filtered_row_groups)); - } - - // Call with filtered_row_group_indices - return apply_bloom_filters( - sources, filtered_row_group_indices, output_dtypes, output_column_schemas, filter, stream); + // Return bloom filtered row group indices iff collected + return bloom_filtered_row_groups.has_value() ? bloom_filtered_row_groups + : filtered_row_group_indices; } // convert column named expression to column index reference expression @@ -647,4 +616,59 @@ class names_from_expression : public ast::detail::expression_transformer { return names_from_expression(expr, skip_names).to_vector(); } +std::optional>> collect_filtered_row_group_indices( + cudf::table_view table, + std::reference_wrapper expr_ast, + host_span const> input_row_group_indices, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) +{ + // Filter the input table using equality predicate evaluator + auto predicate_col = cudf::detail::compute_column(table, expr_ast.get(), stream, mr); + auto predicate = predicate_col->view(); + CUDF_EXPECTS(predicate.type().id() == cudf::type_id::BOOL8, + "Filter expression must return a boolean column"); + + auto const host_bitmask = [&] { + auto const num_bitmasks = num_bitmask_words(predicate.size()); + if (predicate.nullable()) { + return cudf::detail::make_host_vector_sync( + device_span(predicate.null_mask(), num_bitmasks), stream); + } else { + auto bitmask = cudf::detail::make_host_vector(num_bitmasks, stream); + std::fill(bitmask.begin(), bitmask.end(), ~bitmask_type{0}); + return bitmask; + } + }(); + + auto validity_it = cudf::detail::make_counting_transform_iterator( + 0, [bitmask = host_bitmask.data()](auto bit_index) { return bit_is_set(bitmask, bit_index); }); + + auto const is_row_group_required = cudf::detail::make_host_vector_sync( + device_span(predicate.data(), predicate.size()), stream); + + // Return only filtered row groups based on predicate, or all are required, or all are nulls. + if (predicate.null_count() == predicate.size() or std::all_of(is_row_group_required.cbegin(), + is_row_group_required.cend(), + [](auto i) { return bool(i); })) { + return std::nullopt; + } + + // Collect indices of the filtered row groups + size_type is_required_idx = 0; + std::vector> filtered_row_group_indices; + for (auto const& input_row_group_index : input_row_group_indices) { + std::vector filtered_row_groups; + for (auto const rg_idx : input_row_group_index) { + if ((!validity_it[is_required_idx]) || is_row_group_required[is_required_idx]) { + filtered_row_groups.push_back(rg_idx); + } + ++is_required_idx; + } + filtered_row_group_indices.push_back(std::move(filtered_row_groups)); + } + + return {filtered_row_group_indices}; +} + } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 4a30b8b4593..c2ce897ce9b 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -366,7 +366,7 @@ class aggregate_reader_metadata { * @param row_group_indices Lists of row groups to read, one per source * @param output_dtypes Datatypes of of output columns * @param output_column_schemas schema indices of output columns - * @param filter AST expression to filter row groups based on Column chunk statistics + * @param filter AST expression to filter row groups based on bloom filter membership * @param stream CUDA stream used for device memory operations and kernel launches * * @return Filtered row group indices, if any is filtered. @@ -489,4 +489,23 @@ class named_to_reference_converter : public ast::detail::expression_transformer std::optional> expr, std::vector const& skip_names); +/** + * @brief Filter table using the provided (StatsAST or BloomfilterAST) expression and + * collect filtered row group indices + * + * @param table Table of stats or bloom filter membership columns + * @param expr_ast StatsAST or BloomfilterAST expression to evaluate. + * @param input_row_group_indices Lists of input row groups to read, one per source + * @param stream CUDA stream used for device memory operations and kernel launches + * @param mr Device memory resource used to be used in cudf::compute_column + * + * @return Collected filtered row group indices, if any. + */ +[[nodiscard]] std::optional>> collect_filtered_row_group_indices( + cudf::table_view ast_table, + std::reference_wrapper expr_ast, + host_span const> input_row_group_indices, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr); + } // namespace cudf::io::parquet::detail From f9f17469269268c887a2e806ff5af60a870c23cb Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 07:12:24 +0000 Subject: [PATCH 29/82] minor style fix --- cpp/src/io/parquet/bloom_filter_reader.cu | 2 +- cpp/tests/hashing/bloom_filter_test.cu | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 5866c42075c..ef59d313505 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -108,7 +108,7 @@ struct bloom_filter_caster { num_filter_blocks, {}, // thread scope since the same literal is being search across different // bitsets per thread - {}}; // Arrow policy with default xxhash_64, seed = 0 for Arrow compatiblity + {}}; // Arrow policy with default xxhash_64, seed = 0 for Arrow compatibility // Query the bloom filter and store results results[row_group_idx] = filter.contains(d_scalar.value()); diff --git a/cpp/tests/hashing/bloom_filter_test.cu b/cpp/tests/hashing/bloom_filter_test.cu index e0e98dd84a0..c064b3cf0ff 100644 --- a/cpp/tests/hashing/bloom_filter_test.cu +++ b/cpp/tests/hashing/bloom_filter_test.cu @@ -92,4 +92,4 @@ TYPED_TEST(BloomFilter_TestTyped, TestStrings) // Check CUDF_TEST_EXPECT_COLUMNS_EQUAL(output, d_expected); } -*/ \ No newline at end of file +*/ From bad484f7155223b7002958747cc42222f93e9b8e Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 07:41:21 +0000 Subject: [PATCH 30/82] refactoring --- cpp/src/io/parquet/bloom_filter_reader.cu | 37 +++++++++++------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index ef59d313505..a753ed76ba4 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -17,7 +17,6 @@ #include "arrow_filter_policy.cuh" #include "compact_protocol_reader.hpp" #include "io/parquet/parquet.hpp" -#include "io/parquet/parquet_gpu.hpp" #include "reader_impl_helpers.hpp" #include @@ -28,6 +27,8 @@ #include #include #include +#include +#include #include #include @@ -36,20 +37,16 @@ #include #include -#include -#include #include -#include #include #include -#include namespace cudf::io::parquet::detail { namespace { /** - * @brief Converts bloom filter query results for column chunks to a device column. + * @brief Converts bloom filter membership results for column chunks to a device column. * */ struct bloom_filter_caster { @@ -124,7 +121,7 @@ struct bloom_filter_caster { }; /** - * @brief Collects lists of equality predicate literals in the AST expression, one list per input + * @brief Collects lists of equality predicate literals in the AST expression, one list per input * table column. This is used in row group filtering based on bloom filters. */ class equality_literals_collector : public ast::detail::expression_transformer { @@ -143,7 +140,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { */ std::reference_wrapper visit(ast::literal const& expr) override { - _equality_expr = std::reference_wrapper(expr); + _bloom_filter_expr = std::reference_wrapper(expr); return expr; } @@ -153,10 +150,10 @@ class equality_literals_collector : public ast::detail::expression_transformer { std::reference_wrapper visit(ast::column_reference const& expr) override { CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, - "Equality AST supports only left table"); + "BloomfilterAST supports only left table"); CUDF_EXPECTS(expr.get_column_index() < _num_input_columns, "Column index cannot be more than number of columns in the table"); - _equality_expr = std::reference_wrapper(expr); + _bloom_filter_expr = std::reference_wrapper(expr); return expr; } @@ -166,7 +163,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { std::reference_wrapper visit( ast::column_name_reference const& expr) override { - CUDF_FAIL("Column name reference is not supported in equality AST"); + CUDF_FAIL("Column name reference is not supported in BloomfilterAST"); } /** @@ -186,7 +183,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { "Second operand of binary operation with column reference must be a literal"); v->accept(*this); - // Push to the corresponding column's literals list if equality predicate seen + // Push to the corresponding column's literals list iff equality predicate is seen if (op == ast_operator::EQUAL) { auto const col_idx = v->get_column_index(); _equality_literals[col_idx].emplace_back( @@ -200,7 +197,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { _operators.emplace_back(op, new_operands.front()); } } - _equality_expr = std::reference_wrapper(_operators.back()); + _bloom_filter_expr = std::reference_wrapper(_operators.back()); return std::reference_wrapper(_operators.back()); } @@ -225,7 +222,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { } return transformed_operands; } - std::optional> _equality_expr; + std::optional> _bloom_filter_expr; std::vector> _equality_literals; std::list _col_ref; std::list _operators; @@ -311,18 +308,18 @@ class bloom_filter_expression_converter : public equality_literals_collector { _operators.emplace_back(op, new_operands.front()); } } - _equality_expr = std::reference_wrapper(_operators.back()); + _bloom_filter_expr = std::reference_wrapper(_operators.back()); return std::reference_wrapper(_operators.back()); } /** - * @brief Returns the AST to apply on bloom filters + * @brief Returns the AST to apply on bloom filters mmebership. * * @return AST operation expression */ - [[nodiscard]] std::reference_wrapper get_equality_expr() const + [[nodiscard]] std::reference_wrapper get_bloom_filter_expr() const { - return _equality_expr.value().get(); + return _bloom_filter_expr.value().get(); } private: @@ -593,13 +590,13 @@ std::optional>> aggregate_reader_metadata::ap // Convert AST to BloomfilterAST expression with reference to bloom filter membership // in above `bloom_filter_membership_table` - bloom_filter_expression_converter equality_expr{ + bloom_filter_expression_converter bloom_filter_expr{ filter.get(), num_input_columns, equality_literals}; // Filter bloom filter membership table with the BloomfilterAST expression and collect filtered // row group indices return collect_filtered_row_group_indices(bloom_filter_membership_table, - equality_expr.get_equality_expr(), + bloom_filter_expr.get_bloom_filter_expr(), row_group_indices, stream, mr); From ce09d43ac8dbded82c7d3370f95f67204eaba4fa Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 07:47:06 +0000 Subject: [PATCH 31/82] Minor refactoring --- cpp/src/io/parquet/bloom_filter_reader.cu | 115 +++++++++++----------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index a753ed76ba4..91eeba69ca0 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -356,68 +356,71 @@ std::future read_bloom_filter_data_async( thrust::make_counting_iterator(0), thrust::make_counting_iterator(num_chunks), [&](auto const chunk) { - // Read bloom filter if present - if (bloom_filter_offsets[chunk].has_value()) { - auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); - // If Bloom filter size (header + bitset) is available, just read the entire thing. - // Else just read 256 bytes which will contain the entire header and may contain the - // entire bitset as well. - auto constexpr bloom_filter_size_guess = 256; - auto const initial_read_size = - static_cast(bloom_filter_sizes[chunk].value_or(bloom_filter_size_guess)); - - // Read an initial buffer from source - auto& source = sources[chunk_source_map[chunk]]; - auto buffer = source->host_read(bloom_filter_offset, initial_read_size); - - // Deserialize the Bloom filter header from the buffer. - BloomFilterHeader header; - CompactProtocolReader cp{buffer->data(), buffer->size()}; - cp.read(&header); - - // Test if header is valid. - auto const is_header_valid = - (header.num_bytes % 32) == 0 and - header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and - header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and - header.hash.hash == BloomFilterHash::Hash::XXHASH; - - // Do not read if the bloom filter is invalid - if (not is_header_valid) { - CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); - return; - } + // Skip if bloom filter offset absent + if (not bloom_filter_offsets[chunk].has_value()) { return; } + + // Read bloom filter iff present + auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); + + // If Bloom filter size (header + bitset) is available, just read the entire thing. + // Else just read 256 bytes which will contain the entire header and may contain the + // entire bitset as well. + auto constexpr bloom_filter_size_guess = 256; + auto const initial_read_size = + static_cast(bloom_filter_sizes[chunk].value_or(bloom_filter_size_guess)); + + // Read an initial buffer from source + auto& source = sources[chunk_source_map[chunk]]; + auto buffer = source->host_read(bloom_filter_offset, initial_read_size); + + // Deserialize the Bloom filter header from the buffer. + BloomFilterHeader header; + CompactProtocolReader cp{buffer->data(), buffer->size()}; + cp.read(&header); + + // Check if the bloom filter header is valid. + auto const is_header_valid = + (header.num_bytes % 32) == 0 and + header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and + header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and + header.hash.hash == BloomFilterHash::Hash::XXHASH; + + // Do not read if the bloom filter is invalid + if (not is_header_valid) { + CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); + return; + } - // Bloom filter header size - auto const bloom_filter_header_size = static_cast(cp.bytecount()); - size_t const bitset_size = header.num_bytes; + // Bloom filter header size + auto const bloom_filter_header_size = static_cast(cp.bytecount()); + size_t const bitset_size = static_cast(header.num_bytes); - // Check if we already read in the filter bitset in the initial read. - if (initial_read_size >= bloom_filter_header_size + bitset_size) { - bloom_filter_data[chunk] = - rmm::device_buffer{buffer->data() + bloom_filter_header_size, bitset_size, stream}; - } - // Read the bitset from datasource. - else { - auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; - // Directly read to device if preferred - if (source->is_device_read_preferred(bitset_size)) { - bloom_filter_data[chunk] = rmm::device_buffer{bitset_size, stream}; - auto future_read_size = - source->device_read_async(bitset_offset, - bitset_size, - static_cast(bloom_filter_data[chunk].data()), - stream); - - read_tasks.emplace_back(std::move(future_read_size)); - } else { - buffer = source->host_read(bitset_offset, bitset_size); - bloom_filter_data[chunk] = rmm::device_buffer{buffer->data(), buffer->size(), stream}; - } + // Check if we already read in the filter bitset in the initial read. + if (initial_read_size >= bloom_filter_header_size + bitset_size) { + bloom_filter_data[chunk] = + rmm::device_buffer{buffer->data() + bloom_filter_header_size, bitset_size, stream}; + } + // Read the bitset from datasource. + else { + auto const bitset_offset = bloom_filter_offset + bloom_filter_header_size; + // Directly read to device if preferred + if (source->is_device_read_preferred(bitset_size)) { + bloom_filter_data[chunk] = rmm::device_buffer{bitset_size, stream}; + auto future_read_size = + source->device_read_async(bitset_offset, + bitset_size, + static_cast(bloom_filter_data[chunk].data()), + stream); + + read_tasks.emplace_back(std::move(future_read_size)); + } else { + buffer = source->host_read(bitset_offset, bitset_size); + bloom_filter_data[chunk] = rmm::device_buffer{buffer->data(), buffer->size(), stream}; } } }); + // Read task sync function auto sync_fn = [](decltype(read_tasks) read_tasks) { for (auto& task : read_tasks) { task.wait(); From dddee6ca406f0d4e9e4c0597c329b3b8f899dcf7 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 08:16:25 +0000 Subject: [PATCH 32/82] Minor improvements --- cpp/src/io/parquet/bloom_filter_reader.cu | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 91eeba69ca0..1d9ef7a6742 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -206,7 +206,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { * * @return Vectors of equality literals, one per input table column */ - [[nodiscard]] std::vector> get_equality_literals() const + [[nodiscard]] std::vector> const get_equality_literals() const { return _equality_literals; } @@ -286,17 +286,18 @@ class bloom_filter_expression_converter : public equality_literals_collector { v->accept(*this); if (op == ast_operator::EQUAL) { - auto const literal_ptr = - const_cast(dynamic_cast(&operands[1].get())); + // Search the literal in this input column's equality literals list and add to the offset. auto const col_idx = v->get_column_index(); auto const& equality_literals = _equality_literals[col_idx]; - auto col_ref_offset = _col_literals_offsets[col_idx]; - auto const ptr = - std::find(equality_literals.cbegin(), equality_literals.cend(), literal_ptr); - CUDF_EXPECTS(ptr != equality_literals.end(), "Could not find the literal ptr"); - col_ref_offset += std::distance(equality_literals.cbegin(), ptr); - - auto const& value = _col_ref.emplace_back(col_ref_offset); + auto col_literal_offset = _col_literals_offsets[col_idx]; + auto const literal_iter = std::find(equality_literals.cbegin(), + equality_literals.cend(), + dynamic_cast(&operands[1].get())); + CUDF_EXPECTS(literal_iter != equality_literals.end(), "Could not find the literal ptr"); + col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); + + // Evaluate boolean is_true(value) expression as NOT(NOT(value)) + auto const& value = _col_ref.emplace_back(col_literal_offset); auto const& op = _operators.emplace_back(ast_operator::NOT, value); _operators.emplace_back(ast_operator::NOT, op); } From 0cfeb80a8a413d22c947b9dfa1782c38ac1dca52 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 21:10:48 +0000 Subject: [PATCH 33/82] Add gtest --- cpp/src/io/parquet/predicate_pushdown.cpp | 9 +++--- cpp/src/io/parquet/reader_impl_helpers.hpp | 4 +-- cpp/tests/hashing/bloom_filter_test.cu | 36 ++++++++++------------ 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 760b2d18eec..ba6f690abd3 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -618,13 +618,13 @@ class names_from_expression : public ast::detail::expression_transformer { std::optional>> collect_filtered_row_group_indices( cudf::table_view table, - std::reference_wrapper expr_ast, + std::reference_wrapper ast_expr, host_span const> input_row_group_indices, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { - // Filter the input table using equality predicate evaluator - auto predicate_col = cudf::detail::compute_column(table, expr_ast.get(), stream, mr); + // Filter the input table using AST expression + auto predicate_col = cudf::detail::compute_column(table, ast_expr.get(), stream, mr); auto predicate = predicate_col->view(); CUDF_EXPECTS(predicate.type().id() == cudf::type_id::BOOL8, "Filter expression must return a boolean column"); @@ -644,10 +644,11 @@ std::optional>> collect_filtered_row_group_in auto validity_it = cudf::detail::make_counting_transform_iterator( 0, [bitmask = host_bitmask.data()](auto bit_index) { return bit_is_set(bitmask, bit_index); }); + // Return only filtered row groups based on predicate auto const is_row_group_required = cudf::detail::make_host_vector_sync( device_span(predicate.data(), predicate.size()), stream); - // Return only filtered row groups based on predicate, or all are required, or all are nulls. + // Return if all are required, or all are nulls. if (predicate.null_count() == predicate.size() or std::all_of(is_row_group_required.cbegin(), is_row_group_required.cend(), [](auto i) { return bool(i); })) { diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index c2ce897ce9b..6cb99a54720 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -494,7 +494,7 @@ class named_to_reference_converter : public ast::detail::expression_transformer * collect filtered row group indices * * @param table Table of stats or bloom filter membership columns - * @param expr_ast StatsAST or BloomfilterAST expression to evaluate. + * @param ast_expr StatsAST or BloomfilterAST expression to filter with. * @param input_row_group_indices Lists of input row groups to read, one per source * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to be used in cudf::compute_column @@ -503,7 +503,7 @@ class named_to_reference_converter : public ast::detail::expression_transformer */ [[nodiscard]] std::optional>> collect_filtered_row_group_indices( cudf::table_view ast_table, - std::reference_wrapper expr_ast, + std::reference_wrapper ast_expr, host_span const> input_row_group_indices, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); diff --git a/cpp/tests/hashing/bloom_filter_test.cu b/cpp/tests/hashing/bloom_filter_test.cu index c064b3cf0ff..9ca76c085b6 100644 --- a/cpp/tests/hashing/bloom_filter_test.cu +++ b/cpp/tests/hashing/bloom_filter_test.cu @@ -14,13 +14,16 @@ * limitations under the License. */ -/* +// Remove this after cuco bump +#include "src/io/parquet/arrow_filter_policy.cuh" + #include #include #include #include #include +#include #include #include @@ -28,19 +31,16 @@ using StringType = cudf::string_view; -template -class BloomFilter_TestTyped : public cudf::test::BaseFixture {}; - -TYPED_TEST_SUITE(BloomFilter_TestTyped, StringType); +class BloomFilterTest : public cudf::test::BaseFixture {}; -TYPED_TEST(BloomFilter_TestTyped, TestStrings) +TEST_F(BloomFilterTest, TestStrings) { using key_type = StringType; using hasher_type = cudf::hashing::detail::XXHash_64; using policy_type = cuco::arrow_filter_policy; + using word_type = policy_type::word_type; std::size_t constexpr num_filter_blocks = 4; - std::size_t constexpr num_keys = 50; auto stream = cudf::get_default_stream(); // strings data @@ -64,12 +64,11 @@ TYPED_TEST(BloomFilter_TestTyped, TestStrings) cudf::detail::cuco_allocator> filter{num_filter_blocks, cuco::thread_scope_device, - {hasher_type{0}}, + {hasher_type{cudf::DEFAULT_HASH_SEED}}, cudf::detail::cuco_allocator{rmm::mr::polymorphic_allocator{}, stream}, stream}; // Add strings to the bloom filter - auto col = table.column(0); filter.add(d_column->begin(), d_column->end(), stream); // Number of words in the filter @@ -78,18 +77,15 @@ TYPED_TEST(BloomFilter_TestTyped, TestStrings) auto const output = cudf::column_view{ cudf::data_type{cudf::type_id::UINT32}, num_words, filter.data(), nullptr, 0, 0, {}}; - using word_type = filter_type::word_type; - // Expected filter bitset words computed using Arrow implementation here: // https://godbolt.org/z/oKfqcPWbY auto expected = cudf::test::fixed_width_column_wrapper( - {4194306, 4194305, 2359296, 1073774592, 524544, 1024, 268443648, 8519680, - 2147500040, 8421380, 269500416, 4202624, 8396802, 100665344, 2147747840, 5243136, - 131146, 655364, 285345792, 134222340, 545390596, 2281717768, 51201, 41943553, - 1619656708, 67441680, 8462730, 361220, 2216738864, 587333888, 4219272, 873463873}); - auto d_expected = cudf::column_device_view::create(expected); - - // Check - CUDF_TEST_EXPECT_COLUMNS_EQUAL(output, d_expected); + {4194306U, 4194305U, 2359296U, 1073774592U, 524544U, 1024U, 268443648U, + 8519680U, 2147500040U, 8421380U, 269500416U, 4202624U, 8396802U, 100665344U, + 2147747840U, 5243136U, 131146U, 655364U, 285345792U, 134222340U, 545390596U, + 2281717768U, 51201U, 41943553U, 1619656708U, 67441680U, 8462730U, 361220U, + 2216738864U, 587333888U, 4219272U, 873463873U}); + + // Check the bitset for equality + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output, expected); } -*/ From 91375850432305ef41fed391d8d99699aa47d731 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 26 Nov 2024 23:20:32 +0000 Subject: [PATCH 34/82] Improvements --- cpp/src/io/parquet/bloom_filter_reader.cu | 40 +++++++++++++++++------ 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 1d9ef7a6742..d413a7b6b99 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -93,7 +94,15 @@ struct bloom_filter_caster { num_equality_columns = num_equality_columns, results = reinterpret_cast(results.data())] __device__(auto row_group_idx) { // Filter bitset buffer index - auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); + auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); + + // If no bloom filter, then fill in `true` as membership cannot be determined + if (buffer_sizes[filter_idx] == 0) { + results[row_group_idx] = true; + return; + } + + // Number of filter blocks auto const num_filter_blocks = buffer_sizes[filter_idx] / (word_size * words_per_block); // Create a bloom filter view. @@ -101,11 +110,13 @@ struct bloom_filter_caster { cuco::extent, cuco::thread_scope_thread, policy_type> - filter{reinterpret_cast(buffer_ptrs[filter_idx]), - num_filter_blocks, - {}, // thread scope since the same literal is being search across different - // bitsets per thread - {}}; // Arrow policy with default xxhash_64, seed = 0 for Arrow compatibility + filter{ + reinterpret_cast(buffer_ptrs[filter_idx]), + num_filter_blocks, + cuco::thread_scope_thread, // Thread scope since the same literal is being search + // across (read-only) different bitsets per thread + {hasher_type{cudf::DEFAULT_HASH_SEED}}}; // Arrow policy with cudf::xxhash_64 seeded + // with 0 for Arrow compatibility // Query the bloom filter and store results results[row_group_idx] = filter.contains(d_scalar.value()); @@ -357,9 +368,11 @@ std::future read_bloom_filter_data_async( thrust::make_counting_iterator(0), thrust::make_counting_iterator(num_chunks), [&](auto const chunk) { - // Skip if bloom filter offset absent - if (not bloom_filter_offsets[chunk].has_value()) { return; } - + // If bloom filter offset absent, fill in an empty buffer and skip ahead + if (not bloom_filter_offsets[chunk].has_value()) { + bloom_filter_data[chunk] = {}; + return; + } // Read bloom filter iff present auto const bloom_filter_offset = bloom_filter_offsets[chunk].value(); @@ -379,15 +392,22 @@ std::future read_bloom_filter_data_async( CompactProtocolReader cp{buffer->data(), buffer->size()}; cp.read(&header); + // Get the hardcoded words_per_block value from `cuco::arrow_filter_policy` using a temporary + // `std::byte` key type. + auto constexpr words_per_block = + cuco::arrow_filter_policy>::words_per_block; + // Check if the bloom filter header is valid. auto const is_header_valid = - (header.num_bytes % 32) == 0 and + (header.num_bytes % words_per_block) == 0 and header.compression.compression == BloomFilterCompression::Compression::UNCOMPRESSED and header.algorithm.algorithm == BloomFilterAlgorithm::Algorithm::SPLIT_BLOCK and header.hash.hash == BloomFilterHash::Hash::XXHASH; // Do not read if the bloom filter is invalid if (not is_header_valid) { + bloom_filter_data[chunk] = {}; CUDF_LOG_WARN("Encountered an invalid bloom filter header. Skipping"); return; } From 77152b43cb10f45e57de79f1201c25b92e69a296 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 27 Nov 2024 00:46:31 +0000 Subject: [PATCH 35/82] Support int96 in bloom filter --- cpp/src/io/parquet/bloom_filter_reader.cu | 183 +++++++++++++-------- cpp/src/io/parquet/reader_impl_helpers.hpp | 14 +- 2 files changed, 131 insertions(+), 66 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index d413a7b6b99..640ae0d3d22 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -26,7 +26,6 @@ #include #include #include -#include #include #include #include @@ -53,9 +52,85 @@ namespace { struct bloom_filter_caster { cudf::device_span buffer_ptrs; cudf::device_span buffer_sizes; + host_span parquet_types; size_t num_row_groups; size_t num_equality_columns; + template + std::unique_ptr compute_column(cudf::size_type equality_col_idx, + cudf::data_type dtype, + ast::literal* const& literal, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) const + { + using key_type = T; + using hasher_type = cudf::hashing::detail::XXHash_64; + using policy_type = cuco::arrow_filter_policy; + using word_type = typename policy_type::word_type; + + // Check if the literal has the same type as the column + CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), "Mismatched data_types"); + + // Filter properties + auto constexpr word_size = sizeof(word_type); + auto constexpr words_per_block = policy_type::words_per_block; + + rmm::device_buffer results{num_row_groups, stream, mr}; + + // Query literal in bloom filters. + thrust::for_each( + rmm::exec_policy_nosync(stream), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(num_row_groups), + [buffer_ptrs = buffer_ptrs.data(), + buffer_sizes = buffer_sizes.data(), + d_scalar = literal->get_value(), + col_idx = equality_col_idx, + num_equality_columns = num_equality_columns, + results = reinterpret_cast(results.data())] __device__(auto row_group_idx) { + // Filter bitset buffer index + auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); + + // If no bloom filter, then fill in `true` as membership cannot be determined + if (buffer_sizes[filter_idx] == 0) { + results[row_group_idx] = true; + return; + } + + // Number of filter blocks + auto const num_filter_blocks = buffer_sizes[filter_idx] / (word_size * words_per_block); + + // Create a bloom filter view. + cuco::bloom_filter_ref, + cuco::thread_scope_thread, + policy_type> + filter{reinterpret_cast(buffer_ptrs[filter_idx]), + num_filter_blocks, + cuco::thread_scope_thread, // Thread scope since the same literal is being search + // across (read-only) different bitsets per thread + {hasher_type{0}}}; // Arrow policy with cudf::xxhash_64 + // seeded with 0 for Arrow compatibility + + // If int96_timestamp type, convert literal to string_view and query bloom + // filter + if constexpr (cuda::std::is_same_v and timestamp_is_int96) { + auto const int128_key = static_cast<__int128_t>(d_scalar.value()); + cudf::string_view probe_key{reinterpret_cast(&int128_key), 12}; + results[row_group_idx] = filter.contains(probe_key); + } else { + // Query the bloom filter and store results + results[row_group_idx] = filter.contains(d_scalar.value()); + } + }); + + return std::make_unique(cudf::data_type{cudf::type_id::BOOL8}, + static_cast(num_row_groups), + std::move(results), + rmm::device_buffer{}, + 0); + } + // Creates device columns from bloom filter membership template std::unique_ptr operator()(cudf::size_type equality_col_idx, @@ -67,66 +142,16 @@ struct bloom_filter_caster { // List, Struct, Dictionary types are not supported if constexpr (cudf::is_compound() and not std::is_same_v) { CUDF_FAIL("Compound types don't support equality predicate"); + } else if constexpr (cudf::is_timestamp()) { + if (parquet_types[equality_col_idx] == Type::INT96) { + // For INT96 timestamps, convert to cudf::string_view of 12 bytes + return compute_column( + equality_col_idx, dtype, literal, stream, mr); + } else { + return compute_column(equality_col_idx, dtype, literal, stream, mr); + } } else { - using key_type = T; - using hasher_type = cudf::hashing::detail::XXHash_64; - using policy_type = cuco::arrow_filter_policy; - using word_type = typename policy_type::word_type; - - // Check if the literal has the same type as the column - CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), "Mismatched data_types"); - - // Filter properties - auto constexpr word_size = sizeof(word_type); - auto constexpr words_per_block = policy_type::words_per_block; - - rmm::device_buffer results{num_row_groups, stream, mr}; - - // Query literal in bloom filters. - thrust::for_each( - rmm::exec_policy_nosync(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(num_row_groups), - [buffer_ptrs = buffer_ptrs.data(), - buffer_sizes = buffer_sizes.data(), - d_scalar = literal->get_value(), - col_idx = equality_col_idx, - num_equality_columns = num_equality_columns, - results = reinterpret_cast(results.data())] __device__(auto row_group_idx) { - // Filter bitset buffer index - auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); - - // If no bloom filter, then fill in `true` as membership cannot be determined - if (buffer_sizes[filter_idx] == 0) { - results[row_group_idx] = true; - return; - } - - // Number of filter blocks - auto const num_filter_blocks = buffer_sizes[filter_idx] / (word_size * words_per_block); - - // Create a bloom filter view. - cuco::bloom_filter_ref, - cuco::thread_scope_thread, - policy_type> - filter{ - reinterpret_cast(buffer_ptrs[filter_idx]), - num_filter_blocks, - cuco::thread_scope_thread, // Thread scope since the same literal is being search - // across (read-only) different bitsets per thread - {hasher_type{cudf::DEFAULT_HASH_SEED}}}; // Arrow policy with cudf::xxhash_64 seeded - // with 0 for Arrow compatibility - - // Query the bloom filter and store results - results[row_group_idx] = filter.contains(d_scalar.value()); - }); - - return std::make_unique(cudf::data_type{cudf::type_id::BOOL8}, - static_cast(num_row_groups), - std::move(results), - rmm::device_buffer{}, - 0); + return compute_column(equality_col_idx, dtype, literal, stream, mr); } } }; @@ -523,6 +548,31 @@ std::vector aggregate_reader_metadata::read_bloom_filters( return {}; } +std::vector aggregate_reader_metadata::get_parquet_types( + host_span const> row_group_indices, + host_span column_schemas) const +{ + std::vector parquet_types(column_schemas.size()); + // Find a source with at least one row group + auto const src_iter = std::find_if(row_group_indices.begin(), + row_group_indices.end(), + [](auto const& rg) { return rg.size() > 0; }); + CUDF_EXPECTS(src_iter != row_group_indices.end(), ""); + + // Source index + auto const src_index = std::distance(row_group_indices.begin(), src_iter); + std::transform(column_schemas.begin(), + column_schemas.end(), + parquet_types.begin(), + [&](auto const schema_idx) { + // Use the first row group in this source + auto constexpr row_group_index = 0; + return get_column_metadata(row_group_index, src_index, schema_idx).type; + }); + + return parquet_types; +} + std::optional>> aggregate_reader_metadata::apply_bloom_filters( host_span const> sources, host_span const> row_group_indices, @@ -561,14 +611,17 @@ std::optional>> aggregate_reader_metadata::ap // Return early if no column with equality predicate(s) if (equality_col_schemas.empty()) { return std::nullopt; } - // Read a vector of bloom filter bitset device buffers for all column with equality predicate(s) - // across all row groups + // Read a vector of bloom filter bitset device buffers for all column with equality + // predicate(s) across all row groups auto bloom_filter_data = read_bloom_filters(sources, row_group_indices, equality_col_schemas, num_row_groups, stream); // No bloom filter buffers, return the original row group indices if (bloom_filter_data.empty()) { return std::nullopt; } + // Get parquet types for the predicate columns + auto const parquet_types = get_parquet_types(row_group_indices, equality_col_schemas); + // Copy bloom filter bitset buffer pointers and sizes to device std::vector h_buffer_ptrs(bloom_filter_data.size()); std::vector h_buffer_sizes(bloom_filter_data.size()); @@ -585,7 +638,7 @@ std::optional>> aggregate_reader_metadata::ap // Create a bloom filter query table caster. bloom_filter_caster bloom_filter_col{ - buffer_ptrs, buffer_sizes, num_row_groups, equality_col_schemas.size()}; + buffer_ptrs, buffer_sizes, parquet_types, num_row_groups, equality_col_schemas.size()}; // Converts bloom filter membership for equality predicate columns to a table // containing a column for each `col[i] == literal` predicate to be evaluated. @@ -617,8 +670,8 @@ std::optional>> aggregate_reader_metadata::ap bloom_filter_expression_converter bloom_filter_expr{ filter.get(), num_input_columns, equality_literals}; - // Filter bloom filter membership table with the BloomfilterAST expression and collect filtered - // row group indices + // Filter bloom filter membership table with the BloomfilterAST expression and collect + // filtered row group indices return collect_filtered_row_group_indices(bloom_filter_membership_table, bloom_filter_expr.get_bloom_filter_expr(), row_group_indices, diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 6cb99a54720..c280341ead7 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -205,7 +205,7 @@ class aggregate_reader_metadata { * @param column_schemas Schema indices of columns whose bloom filters will be read * @param stream CUDA stream used for device memory operations and kernel launches * - * @return A flattened list of bloom filter bitset device buffers for each equality column in each + * @return A flattened list of bloom filter bitset device buffers for each predicate column across * row group */ [[nodiscard]] std::vector read_bloom_filters( @@ -215,6 +215,18 @@ class aggregate_reader_metadata { size_type num_row_groups, rmm::cuda_stream_view stream) const; + /** + * @brief Collects Parquet types for the columns with the specified schema indices + * + * @param row_group_indices Lists of row groups, once per source + * @param column_schemas Schema indices of columns whose types will be collected + * + * @return A list of parquet types for the columns matching the procided schema indices + */ + [[nodiscard]] std::vector get_parquet_types( + host_span const> row_group_indices, + host_span column_schemas) const; + public: aggregate_reader_metadata(host_span const> sources, bool use_arrow_schema, From 398429124e8989803cae6ccff2bc8a6f559b2f11 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 27 Nov 2024 00:59:42 +0000 Subject: [PATCH 36/82] Cleanup --- cpp/src/io/parquet/bloom_filter_reader.cu | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 640ae0d3d22..df6d48618b0 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -46,7 +46,7 @@ namespace cudf::io::parquet::detail { namespace { /** - * @brief Converts bloom filter membership results for column chunks to a device column. + * @brief Converts bloom filter membership results (for each column chunk) to a device column. * */ struct bloom_filter_caster { @@ -57,11 +57,11 @@ struct bloom_filter_caster { size_t num_equality_columns; template - std::unique_ptr compute_column(cudf::size_type equality_col_idx, - cudf::data_type dtype, - ast::literal* const& literal, - rmm::cuda_stream_view stream, - rmm::device_async_resource_ref mr) const + std::unique_ptr query_bloom_filter(cudf::size_type equality_col_idx, + cudf::data_type dtype, + ast::literal* const& literal, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) const { using key_type = T; using hasher_type = cudf::hashing::detail::XXHash_64; @@ -69,7 +69,8 @@ struct bloom_filter_caster { using word_type = typename policy_type::word_type; // Check if the literal has the same type as the column - CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), "Mismatched data_types"); + CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), + "Mismatched data types between the column and the literal"); // Filter properties auto constexpr word_size = sizeof(word_type); @@ -77,7 +78,7 @@ struct bloom_filter_caster { rmm::device_buffer results{num_row_groups, stream, mr}; - // Query literal in bloom filters. + // Query literal in bloom filters from each column chunk (row group). thrust::for_each( rmm::exec_policy_nosync(stream), thrust::make_counting_iterator(0), @@ -107,10 +108,9 @@ struct bloom_filter_caster { policy_type> filter{reinterpret_cast(buffer_ptrs[filter_idx]), num_filter_blocks, - cuco::thread_scope_thread, // Thread scope since the same literal is being search - // across (read-only) different bitsets per thread - {hasher_type{0}}}; // Arrow policy with cudf::xxhash_64 - // seeded with 0 for Arrow compatibility + {}, // Thread scope as the same literal is being searched across different bitsets + // per thread + {}}; // Arrow policy with cudf::XXHash_64 seeded with 0 for Arrow compatibility // If int96_timestamp type, convert literal to string_view and query bloom // filter @@ -144,14 +144,14 @@ struct bloom_filter_caster { CUDF_FAIL("Compound types don't support equality predicate"); } else if constexpr (cudf::is_timestamp()) { if (parquet_types[equality_col_idx] == Type::INT96) { - // For INT96 timestamps, convert to cudf::string_view of 12 bytes - return compute_column( + // For INT96 timestamps, use cudf::string_view type and set timestamp_is_int96 + return query_bloom_filter( equality_col_idx, dtype, literal, stream, mr); } else { - return compute_column(equality_col_idx, dtype, literal, stream, mr); + return query_bloom_filter(equality_col_idx, dtype, literal, stream, mr); } } else { - return compute_column(equality_col_idx, dtype, literal, stream, mr); + return query_bloom_filter(equality_col_idx, dtype, literal, stream, mr); } } }; From 9a39aa4be813370275ee320c21fd68116ed22b56 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 27 Nov 2024 01:25:34 +0000 Subject: [PATCH 37/82] Minor improvements --- cpp/src/io/parquet/bloom_filter_reader.cu | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index df6d48618b0..14fda5911e8 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -271,10 +271,9 @@ class equality_literals_collector : public ast::detail::expression_transformer { */ class bloom_filter_expression_converter : public equality_literals_collector { public: - bloom_filter_expression_converter( - ast::expression const& expr, - size_type num_input_columns, - std::vector> const& equality_literals) + bloom_filter_expression_converter(ast::expression const& expr, + size_type num_input_columns, + std::vector>& equality_literals) { // Set the num columns and equality literals _num_input_columns = num_input_columns; @@ -594,7 +593,7 @@ std::optional>> aggregate_reader_metadata::ap [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); // Collect equality literals for each input table column - auto const equality_literals = + auto equality_literals = equality_literals_collector{filter.get(), num_input_columns}.get_equality_literals(); // Collect schema indices of columns with equality predicate(s) @@ -613,7 +612,7 @@ std::optional>> aggregate_reader_metadata::ap // Read a vector of bloom filter bitset device buffers for all column with equality // predicate(s) across all row groups - auto bloom_filter_data = + auto const bloom_filter_data = read_bloom_filters(sources, row_group_indices, equality_col_schemas, num_row_groups, stream); // No bloom filter buffers, return the original row group indices From 1def80102140e02d325a09b03eb866b1bfb9e0b5 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 27 Nov 2024 01:34:14 +0000 Subject: [PATCH 38/82] Fix minor bug --- cpp/src/io/parquet/bloom_filter_reader.cu | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 14fda5911e8..f044b49e55f 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -271,13 +271,14 @@ class equality_literals_collector : public ast::detail::expression_transformer { */ class bloom_filter_expression_converter : public equality_literals_collector { public: - bloom_filter_expression_converter(ast::expression const& expr, - size_type num_input_columns, - std::vector>& equality_literals) + bloom_filter_expression_converter( + ast::expression const& expr, + size_type num_input_columns, + std::vector> const& equality_literals) { - // Set the num columns and equality literals + // Set the num columns and copy equality literals _num_input_columns = num_input_columns; - _equality_literals = std::move(equality_literals); + _equality_literals = equality_literals; // Compute and store columns literals offsets _col_literals_offsets.reserve(_num_input_columns + 1); @@ -593,7 +594,7 @@ std::optional>> aggregate_reader_metadata::ap [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); // Collect equality literals for each input table column - auto equality_literals = + auto const equality_literals = equality_literals_collector{filter.get(), num_input_columns}.get_equality_literals(); // Collect schema indices of columns with equality predicate(s) From 6edc2481447d2170a28a6d1b4645a907d0909e7d Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 28 Nov 2024 01:00:06 +0000 Subject: [PATCH 39/82] MInor bug fixing --- cpp/src/io/parquet/bloom_filter_reader.cu | 14 +++++++------- cpp/src/io/parquet/predicate_pushdown.cpp | 4 ++-- cpp/src/io/parquet/reader_impl_helpers.hpp | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index f044b49e55f..8514ec05cdb 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -575,7 +575,7 @@ std::vector aggregate_reader_metadata::get_parquet_types( std::optional>> aggregate_reader_metadata::apply_bloom_filters( host_span const> sources, - host_span const> row_group_indices, + host_span const> input_row_group_indices, host_span output_dtypes, host_span output_column_schemas, std::reference_wrapper filter, @@ -588,8 +588,8 @@ std::optional>> aggregate_reader_metadata::ap // Total number of row groups to process. auto const num_row_groups = std::accumulate( - row_group_indices.begin(), - row_group_indices.end(), + input_row_group_indices.begin(), + input_row_group_indices.end(), size_t{0}, [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); @@ -613,14 +613,14 @@ std::optional>> aggregate_reader_metadata::ap // Read a vector of bloom filter bitset device buffers for all column with equality // predicate(s) across all row groups - auto const bloom_filter_data = - read_bloom_filters(sources, row_group_indices, equality_col_schemas, num_row_groups, stream); + auto const bloom_filter_data = read_bloom_filters( + sources, input_row_group_indices, equality_col_schemas, num_row_groups, stream); // No bloom filter buffers, return the original row group indices if (bloom_filter_data.empty()) { return std::nullopt; } // Get parquet types for the predicate columns - auto const parquet_types = get_parquet_types(row_group_indices, equality_col_schemas); + auto const parquet_types = get_parquet_types(input_row_group_indices, equality_col_schemas); // Copy bloom filter bitset buffer pointers and sizes to device std::vector h_buffer_ptrs(bloom_filter_data.size()); @@ -674,7 +674,7 @@ std::optional>> aggregate_reader_metadata::ap // filtered row group indices return collect_filtered_row_group_indices(bloom_filter_membership_table, bloom_filter_expr.get_bloom_filter_expr(), - row_group_indices, + input_row_group_indices, stream, mr); } diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index ba6f690abd3..65ea51f50f3 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -450,8 +450,8 @@ std::optional>> aggregate_reader_metadata::fi stats_expression_converter stats_expr{filter.get(), static_cast(output_dtypes.size())}; // Filter stats table with StatsAST expression and collect filtered row group indices - auto filtered_row_group_indices = collect_filtered_row_group_indices( - stats_table, stats_expr.get_stats_expr(), row_group_indices, stream, mr); + auto const filtered_row_group_indices = collect_filtered_row_group_indices( + stats_table, stats_expr.get_stats_expr(), input_row_group_indices, stream, mr); // Span on row groups to apply bloom filtering on. auto const bloom_filter_input_row_groups = diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index c280341ead7..9cc2e0e8be8 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -375,7 +375,7 @@ class aggregate_reader_metadata { * @brief Filters the row groups using bloom filters * * @param sources Dataset sources - * @param row_group_indices Lists of row groups to read, one per source + * @param row_group_indices Lists of input row groups to read, one per source * @param output_dtypes Datatypes of of output columns * @param output_column_schemas schema indices of output columns * @param filter AST expression to filter row groups based on bloom filter membership @@ -385,7 +385,7 @@ class aggregate_reader_metadata { */ [[nodiscard]] std::optional>> apply_bloom_filters( host_span const> sources, - host_span const> row_group_indices, + host_span const> input_row_group_indices, host_span output_dtypes, host_span output_column_schemas, std::reference_wrapper filter, From 2925f1e0098ba57ac79e8f4dfab53c7265bc8100 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 28 Nov 2024 02:19:42 +0000 Subject: [PATCH 40/82] Add python tests --- ...d_ndv_100_bf_fpp0.1_nostats.snappy.parquet | Bin 0 -> 14057 bytes ...ed_card_ndv_100_chunk_stats.snappy.parquet | Bin 0 -> 12413 bytes ...d_ndv_500_bf_fpp0.1_nostats.snappy.parquet | Bin 0 -> 37510 bytes ...ed_card_ndv_500_chunk_stats.snappy.parquet | Bin 0 -> 33028 bytes python/cudf/cudf/tests/test_parquet.py | 54 ++++++++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_bf_fpp0.1_nostats.snappy.parquet create mode 100644 python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_chunk_stats.snappy.parquet create mode 100644 python/cudf/cudf/tests/data/parquet/mixed_card_ndv_500_bf_fpp0.1_nostats.snappy.parquet create mode 100644 python/cudf/cudf/tests/data/parquet/mixed_card_ndv_500_chunk_stats.snappy.parquet diff --git a/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_bf_fpp0.1_nostats.snappy.parquet b/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_bf_fpp0.1_nostats.snappy.parquet new file mode 100644 index 0000000000000000000000000000000000000000..8c40a225c44f946689afcb71c495fcadaf195a22 GIT binary patch literal 14057 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�<}b5g6F)7hn=lI1ib` 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 literal 0 HcmV?d00001 diff --git a/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_500_chunk_stats.snappy.parquet b/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_500_chunk_stats.snappy.parquet new file mode 100644 index 0000000000000000000000000000000000000000..746916cf07dc329f95ccd024cd8a2645920b4bf8 GIT binary patch literal 33028 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 Do`x(* literal 0 HcmV?d00001 diff --git a/python/cudf/cudf/tests/test_parquet.py b/python/cudf/cudf/tests/test_parquet.py index 659d2ebd89a..6ba0f78d434 100644 --- a/python/cudf/cudf/tests/test_parquet.py +++ b/python/cudf/cudf/tests/test_parquet.py @@ -1,6 +1,7 @@ # Copyright (c) 2019-2024, NVIDIA CORPORATION. import datetime +import decimal import glob import hashlib import math @@ -4341,3 +4342,56 @@ def test_parquet_reader_mismatched_nullability_structs(tmpdir): cudf.read_parquet([buf2, buf1]), cudf.concat([df2, df1]).reset_index(drop=True), ) + + +@pytest.mark.parametrize( + "stats_fname,bloom_filter_fname", + [ + ( + "mixed_card_ndv_100_chunk_stats.snappy.parquet", + "mixed_card_ndv_100_bf_fpp0.1_nostats.snappy.parquet", + ), + ( + "mixed_card_ndv_500_chunk_stats.snappy.parquet", + "mixed_card_ndv_500_bf_fpp0.1_nostats.snappy.parquet", + ), + ], +) +@pytest.mark.parametrize( + "predicate,expected_len", + [ + ([[("str", "==", "FINDME")], [("fp64", "==", float(500))]], 2), + ([("fixed_pt", "==", decimal.Decimal(float(500)))], 2), + ([[("ui32", "==", np.uint32(500)), ("str", "==", "FINDME")]], 2), + ( + [ + ("str", "!=", "FINDME"), + ("fixed_pt", "==", decimal.Decimal(float(500))), + ], + 0, + ), + ], +) +def test_parquet_bloom_filters( + datadir, stats_fname, bloom_filter_fname, predicate, expected_len +): + fname_stats = datadir / stats_fname + fname_bf = datadir / bloom_filter_fname + df_stats = cudf.read_parquet(fname_stats, filters=predicate).reset_index( + drop=True + ) + df_bf = cudf.read_parquet(fname_bf, filters=predicate).reset_index( + drop=True + ) + + # Check if tables equal + assert_eq( + df_stats, + df_bf, + ) + + # Check for table length + assert_eq( + len(df_stats), + expected_len, + ) From efc6ec0a5c4b956ba86d97259aff6f31b5443401 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 28 Nov 2024 02:28:03 +0000 Subject: [PATCH 41/82] Correct parquet files --- ...d_ndv_100_bf_fpp0.1_nostats.snappy.parquet | Bin 14057 -> 41508 bytes ...d_ndv_500_bf_fpp0.1_nostats.snappy.parquet | Bin 37510 -> 77985 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_bf_fpp0.1_nostats.snappy.parquet b/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_bf_fpp0.1_nostats.snappy.parquet index 8c40a225c44f946689afcb71c495fcadaf195a22..4123545a6e030b7c0b1b7852571348ed53074f72 100644 GIT binary patch literal 41508 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 Date: Mon, 2 Dec 2024 19:13:09 +0000 Subject: [PATCH 42/82] minor spelling fix --- cpp/src/io/parquet/reader_impl_helpers.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 9cc2e0e8be8..f1d55bef326 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -221,7 +221,7 @@ class aggregate_reader_metadata { * @param row_group_indices Lists of row groups, once per source * @param column_schemas Schema indices of columns whose types will be collected * - * @return A list of parquet types for the columns matching the procided schema indices + * @return A list of parquet types for the columns matching the provided schema indices */ [[nodiscard]] std::vector get_parquet_types( host_span const> row_group_indices, From fa0cec85d543c24ff005e9f2dacbda7282c92170 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:09:56 -0800 Subject: [PATCH 43/82] Apply suggestions from code review Co-authored-by: Bradley Dice --- cpp/src/io/parquet/reader_impl_helpers.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index f1d55bef326..7e9881e8947 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -355,7 +355,7 @@ class aggregate_reader_metadata { /** * @brief Filters the row groups based on predicate filter * - * @param sources Lists of input datasources. + * @param sources Lists of input datasources * @param row_group_indices Lists of row groups to read, one per source * @param output_dtypes Datatypes of of output columns * @param output_column_schemas schema indices of output columns @@ -397,7 +397,7 @@ class aggregate_reader_metadata { * The input `row_start` and `row_count` parameters will be recomputed and output as the valid * values based on the input row group list. * - * @param sources Lists of input datasources. + * @param sources Lists of input datasources * @param row_group_indices Lists of row groups to read, one per source * @param row_start Starting row of the selection * @param row_count Total number of rows selected From 7a309c691bb9c4ddf33451265231c5a2ab7d57c6 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:11:11 +0000 Subject: [PATCH 44/82] Minor bug fix --- cpp/include/cudf/hashing/detail/xxhash_64.cuh | 3 ++- cpp/src/io/parquet/bloom_filter_reader.cu | 16 ++++++++-------- cpp/src/io/parquet/reader_impl_helpers.hpp | 10 +++++----- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/cpp/include/cudf/hashing/detail/xxhash_64.cuh b/cpp/include/cudf/hashing/detail/xxhash_64.cuh index 7d72349e340..53b3fbad842 100644 --- a/cpp/include/cudf/hashing/detail/xxhash_64.cuh +++ b/cpp/include/cudf/hashing/detail/xxhash_64.cuh @@ -29,7 +29,8 @@ namespace cudf::hashing::detail { template struct XXHash_64 { - using result_type = std::uint64_t; + using argument_type = Key; + using result_type = std::uint64_t; __host__ __device__ constexpr XXHash_64(uint64_t seed = cudf::DEFAULT_HASH_SEED) : _impl{seed} {} diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 8514ec05cdb..dcbf17003d0 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -59,7 +59,7 @@ struct bloom_filter_caster { template std::unique_ptr query_bloom_filter(cudf::size_type equality_col_idx, cudf::data_type dtype, - ast::literal* const& literal, + ast::literal const* const literal, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) const { @@ -135,7 +135,7 @@ struct bloom_filter_caster { template std::unique_ptr operator()(cudf::size_type equality_col_idx, cudf::data_type dtype, - ast::literal* const& literal, + ast::literal* const literal, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) const { @@ -215,15 +215,15 @@ class equality_literals_collector : public ast::detail::expression_transformer { // First operand should be column reference, second should be literal. CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, "Only binary operations are supported on column reference"); - CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, + auto const literal_ptr = dynamic_cast(&operands[1].get()); + CUDF_EXPECTS(literal_ptr != nullptr, "Second operand of binary operation with column reference must be a literal"); v->accept(*this); // Push to the corresponding column's literals list iff equality predicate is seen if (op == ast_operator::EQUAL) { auto const col_idx = v->get_column_index(); - _equality_literals[col_idx].emplace_back( - const_cast(dynamic_cast(&operands[1].get()))); + _equality_literals[col_idx].emplace_back(const_cast(literal_ptr)); } } else { auto new_operands = visit_operands(operands); @@ -266,7 +266,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { }; /** - * @brief Converts AST expression to bloom filter membership (BloomfilterAST) expression . + * @brief Converts AST expression to bloom filter membership (BloomfilterAST) expression. * This is used in row group filtering based on equality predicate. */ class bloom_filter_expression_converter : public equality_literals_collector { @@ -368,7 +368,7 @@ class bloom_filter_expression_converter : public equality_literals_collector { * * @param sources Dataset sources * @param num_chunks Number of total column chunks to read - * @param bloom_filter_data Devicebuffers to hold bloom filter bitsets for each chunk + * @param bloom_filter_data Device buffers to hold bloom filter bitsets for each chunk * @param bloom_filter_offsets Bloom filter offsets for all chunks * @param bloom_filter_sizes Bloom filter sizes for all chunks * @param chunk_source_map Association between each column chunk and its source @@ -657,7 +657,7 @@ std::optional>> aggregate_reader_metadata::ap if (cudf::is_compound(dtype) and dtype.id() != cudf::type_id::STRING) { return; } // Add a column for all literals associated with an equality column - for (ast::literal* const& literal : equality_literals[input_col_idx]) { + for (auto const& literal : equality_literals[input_col_idx]) { columns.emplace_back(cudf::type_dispatcher( dtype, bloom_filter_col, equality_col_idx, dtype, literal, stream, mr)); } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 7e9881e8947..2e3e6c6b6dd 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -361,7 +361,7 @@ class aggregate_reader_metadata { * @param output_column_schemas schema indices of output columns * @param filter AST expression to filter row groups based on Column chunk statistics * @param stream CUDA stream used for device memory operations and kernel launches - * @return Filtered row group indices, if any is filtered. + * @return Filtered row group indices, if any is filtered */ [[nodiscard]] std::optional>> filter_row_groups( host_span const> sources, @@ -381,7 +381,7 @@ class aggregate_reader_metadata { * @param filter AST expression to filter row groups based on bloom filter membership * @param stream CUDA stream used for device memory operations and kernel launches * - * @return Filtered row group indices, if any is filtered. + * @return Filtered row group indices, if any is filtered */ [[nodiscard]] std::optional>> apply_bloom_filters( host_span const> sources, @@ -406,7 +406,7 @@ class aggregate_reader_metadata { * @param filter Optional AST expression to filter row groups based on Column chunk statistics * @param stream CUDA stream used for device memory operations and kernel launches * @return A tuple of corrected row_start, row_count, list of row group indexes and its - * starting row, and list of number of rows per source. + * starting row, and list of number of rows per source */ [[nodiscard]] std::tuple, std::vector> select_row_groups(host_span const> sources, @@ -469,7 +469,7 @@ class named_to_reference_converter : public ast::detail::expression_transformer std::reference_wrapper visit(ast::operation const& expr) override; /** - * @brief Returns the AST to apply on Column chunk statistics. + * @brief Returns the AST to apply on Column chunk statistics * * @return AST operation expression */ @@ -506,7 +506,7 @@ class named_to_reference_converter : public ast::detail::expression_transformer * collect filtered row group indices * * @param table Table of stats or bloom filter membership columns - * @param ast_expr StatsAST or BloomfilterAST expression to filter with. + * @param ast_expr StatsAST or BloomfilterAST expression to filter with * @param input_row_group_indices Lists of input row groups to read, one per source * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to be used in cudf::compute_column From bcc68c0e896fe59973e74fc6bfe78d24a54d5767 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Mon, 2 Dec 2024 23:26:19 +0000 Subject: [PATCH 45/82] Convert to enum class --- cpp/src/io/parquet/parquet.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cpp/src/io/parquet/parquet.hpp b/cpp/src/io/parquet/parquet.hpp index ff8e7a63486..241f6efbae3 100644 --- a/cpp/src/io/parquet/parquet.hpp +++ b/cpp/src/io/parquet/parquet.hpp @@ -400,7 +400,7 @@ struct ColumnChunkMetaData { */ struct BloomFilterAlgorithm { // Block-based Bloom filter. - enum Algorithm { UNDEFINED, SPLIT_BLOCK }; + enum class Algorithm { UNDEFINED, SPLIT_BLOCK }; Algorithm algorithm{Algorithm::SPLIT_BLOCK}; }; @@ -409,7 +409,7 @@ struct BloomFilterAlgorithm { */ struct BloomFilterHash { // xxHash_64 - enum Hash { UNDEFINED, XXHASH }; + enum class Hash { UNDEFINED, XXHASH }; Hash hash{Hash::XXHASH}; }; @@ -417,7 +417,7 @@ struct BloomFilterHash { * @brief The compression used in the bloom filter */ struct BloomFilterCompression { - enum Compression { UNDEFINED, UNCOMPRESSED }; + enum class Compression { UNDEFINED, UNCOMPRESSED }; Compression compression{Compression::UNCOMPRESSED}; }; From 2dce9b1589b0bd046bb89262b0d97801bb6f51aa Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 3 Dec 2024 00:38:33 +0000 Subject: [PATCH 46/82] Apply suggestion from code review --- cpp/src/io/parquet/bloom_filter_reader.cu | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index dcbf17003d0..e42f3dd4111 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -29,6 +29,7 @@ #include #include #include +#include #include #include @@ -68,9 +69,11 @@ struct bloom_filter_caster { using policy_type = cuco::arrow_filter_policy; using word_type = typename policy_type::word_type; - // Check if the literal has the same type as the column - CUDF_EXPECTS(dtype.id() == literal->get_data_type().id(), - "Mismatched data types between the column and the literal"); + // Check if the literal has the same type as the predicate column + CUDF_EXPECTS( + cudf::have_same_types(cudf::column_view{dtype, 0, {}, {}, 0, 0, {}}, + cudf::column_view{literal->get_data_type(), 0, {}, {}, 0, 0, {}}), + "Mismatched predicate column and literal types"); // Filter properties auto constexpr word_size = sizeof(word_type); From e03bea02c97e060d082b95f934662f387a7aa645 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 3 Dec 2024 20:58:07 +0000 Subject: [PATCH 47/82] Suggestions from code reviews --- cpp/src/io/parquet/bloom_filter_reader.cu | 7 ++++++- cpp/src/io/parquet/predicate_pushdown.cpp | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index e42f3dd4111..7901776c7f6 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -40,6 +40,7 @@ #include #include +#include #include #include @@ -476,7 +477,7 @@ std::future read_bloom_filter_data_async( } }; - return std::async(std::launch::deferred, sync_fn, std::move(read_tasks)); + return std::async(std::launch::async, sync_fn, std::move(read_tasks)); } } // namespace @@ -596,6 +597,10 @@ std::optional>> aggregate_reader_metadata::ap size_t{0}, [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); + // Check if we have less than 2B total row groups. + CUDF_EXPECTS(num_row_groups <= std::numeric_limits::max(), + "Total number of row groups exceed the size_type's limit"); + // Collect equality literals for each input table column auto const equality_literals = equality_literals_collector{filter.get(), num_input_columns}.get_equality_literals(); diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 65ea51f50f3..b917c83d524 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include #include @@ -415,18 +416,22 @@ std::optional>> aggregate_reader_metadata::fi } else { input_row_group_indices = row_group_indices; } - auto const total_row_groups = std::accumulate(input_row_group_indices.begin(), - input_row_group_indices.end(), - 0, - [](size_type sum, auto const& per_file_row_groups) { - return sum + per_file_row_groups.size(); - }); + auto const total_row_groups = std::accumulate( + input_row_group_indices.begin(), + input_row_group_indices.end(), + size_t{0}, + [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); + + // Check if we have less than 2B total row groups. + CUDF_EXPECTS(total_row_groups <= std::numeric_limits::max(), + "Total number of row groups exceed the size_type's limit"); // Converts Column chunk statistics to a table // where min(col[i]) = columns[i*2], max(col[i])=columns[i*2+1] // For each column, it contains #sources * #column_chunks_per_src rows. std::vector> columns; - stats_caster stats_col{total_row_groups, per_file_metadata, input_row_group_indices}; + stats_caster stats_col{ + static_cast(total_row_groups), per_file_metadata, input_row_group_indices}; for (size_t col_idx = 0; col_idx < output_dtypes.size(); col_idx++) { auto const schema_idx = output_column_schemas[col_idx]; auto const& dtype = output_dtypes[col_idx]; From 4b0b5edb729936490ba74abd57dca5a7ee58bd2f Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 00:31:09 +0000 Subject: [PATCH 48/82] Apply suggestions from code reviews --- cpp/src/io/parquet/bloom_filter_reader.cu | 50 ++++++++++++----------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 7901776c7f6..fb2b452fe1a 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -52,8 +52,7 @@ namespace { * */ struct bloom_filter_caster { - cudf::device_span buffer_ptrs; - cudf::device_span buffer_sizes; + cudf::device_span const> bloom_filter_spans; host_span parquet_types; size_t num_row_groups; size_t num_equality_columns; @@ -87,34 +86,35 @@ struct bloom_filter_caster { rmm::exec_policy_nosync(stream), thrust::make_counting_iterator(0), thrust::make_counting_iterator(num_row_groups), - [buffer_ptrs = buffer_ptrs.data(), - buffer_sizes = buffer_sizes.data(), + [filter_span = bloom_filter_spans.data(), d_scalar = literal->get_value(), col_idx = equality_col_idx, num_equality_columns = num_equality_columns, results = reinterpret_cast(results.data())] __device__(auto row_group_idx) { // Filter bitset buffer index - auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); + auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); + auto const filter_size = filter_span[filter_idx].size(); // If no bloom filter, then fill in `true` as membership cannot be determined - if (buffer_sizes[filter_idx] == 0) { + if (filter_size == 0) { results[row_group_idx] = true; return; } // Number of filter blocks - auto const num_filter_blocks = buffer_sizes[filter_idx] / (word_size * words_per_block); + auto const num_filter_blocks = filter_size / (word_size * words_per_block); // Create a bloom filter view. cuco::bloom_filter_ref, cuco::thread_scope_thread, policy_type> - filter{reinterpret_cast(buffer_ptrs[filter_idx]), + filter{reinterpret_cast(filter_span[filter_idx].data()), num_filter_blocks, {}, // Thread scope as the same literal is being searched across different bitsets // per thread - {}}; // Arrow policy with cudf::XXHash_64 seeded with 0 for Arrow compatibility + {}}; // Arrow policy with cudf::hashing::detail::XXHash_64 seeded with 0 for Arrow + // compatibility // If int96_timestamp type, convert literal to string_view and query bloom // filter @@ -621,7 +621,7 @@ std::optional>> aggregate_reader_metadata::ap // Read a vector of bloom filter bitset device buffers for all column with equality // predicate(s) across all row groups - auto const bloom_filter_data = read_bloom_filters( + auto bloom_filter_data = read_bloom_filters( sources, input_row_group_indices, equality_col_schemas, num_row_groups, stream); // No bloom filter buffers, return the original row group indices @@ -630,23 +630,25 @@ std::optional>> aggregate_reader_metadata::ap // Get parquet types for the predicate columns auto const parquet_types = get_parquet_types(input_row_group_indices, equality_col_schemas); - // Copy bloom filter bitset buffer pointers and sizes to device - std::vector h_buffer_ptrs(bloom_filter_data.size()); - std::vector h_buffer_sizes(bloom_filter_data.size()); - std::for_each(thrust::make_counting_iterator(0), - thrust::make_counting_iterator(bloom_filter_data.size()), - [&](auto i) { - auto const& buffer = bloom_filter_data[i]; - // Bitset ptr must be non-const to be used in cuco::bloom_filter. - h_buffer_ptrs[i] = const_cast(buffer.data()); - h_buffer_sizes[i] = buffer.size(); - }); - auto buffer_ptrs = cudf::detail::make_device_uvector_async(h_buffer_ptrs, stream, mr); - auto buffer_sizes = cudf::detail::make_device_uvector_async(h_buffer_sizes, stream, mr); + // Create spans from bloom filter bitset buffers + std::vector> h_bloom_filter_spans; + h_bloom_filter_spans.reserve(bloom_filter_data.size()); + std::transform(thrust::make_counting_iterator(0), + thrust::make_counting_iterator(bloom_filter_data.size()), + std::back_inserter(h_bloom_filter_spans), + [&](auto const filter_idx) { + return cudf::device_span{ + static_cast(bloom_filter_data[filter_idx].data()), + bloom_filter_data[filter_idx].size()}; + }); + + // Copy bloom filter bitset spans to device + auto const bloom_filter_spans = + cudf::detail::make_device_uvector_async(h_bloom_filter_spans, stream, mr); // Create a bloom filter query table caster. bloom_filter_caster bloom_filter_col{ - buffer_ptrs, buffer_sizes, parquet_types, num_row_groups, equality_col_schemas.size()}; + bloom_filter_spans, parquet_types, num_row_groups, equality_col_schemas.size()}; // Converts bloom filter membership for equality predicate columns to a table // containing a column for each `col[i] == literal` predicate to be evaluated. From c1256b135939249f79f90293dbe91e9cce967ece Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 05:01:53 +0000 Subject: [PATCH 49/82] Refactor into single table for cudf::compute_column --- cpp/src/io/parquet/bloom_filter_reader.cu | 242 +++--------- cpp/src/io/parquet/predicate_pushdown.cpp | 346 +++++++++--------- cpp/src/io/parquet/reader_impl_helpers.hpp | 93 ++++- ...ed_card_ndv_100_chunk_stats.snappy.parquet | Bin 12413 -> 37036 bytes ...ed_card_ndv_500_chunk_stats.snappy.parquet | Bin 33028 -> 58150 bytes 5 files changed, 298 insertions(+), 383 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index fb2b452fe1a..5054051b042 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -23,7 +23,6 @@ #include #include #include -#include #include #include #include @@ -33,7 +32,6 @@ #include #include -#include #include #include @@ -54,7 +52,7 @@ namespace { struct bloom_filter_caster { cudf::device_span const> bloom_filter_spans; host_span parquet_types; - size_t num_row_groups; + size_t total_row_groups; size_t num_equality_columns; template @@ -79,13 +77,13 @@ struct bloom_filter_caster { auto constexpr word_size = sizeof(word_type); auto constexpr words_per_block = policy_type::words_per_block; - rmm::device_buffer results{num_row_groups, stream, mr}; + rmm::device_buffer results{total_row_groups, stream, mr}; // Query literal in bloom filters from each column chunk (row group). thrust::for_each( rmm::exec_policy_nosync(stream), thrust::make_counting_iterator(0), - thrust::make_counting_iterator(num_row_groups), + thrust::make_counting_iterator(total_row_groups), [filter_span = bloom_filter_spans.data(), d_scalar = literal->get_value(), col_idx = equality_col_idx, @@ -129,7 +127,7 @@ struct bloom_filter_caster { }); return std::make_unique(cudf::data_type{cudf::type_id::BOOL8}, - static_cast(num_row_groups), + static_cast(total_row_groups), std::move(results), rmm::device_buffer{}, 0); @@ -164,47 +162,22 @@ struct bloom_filter_caster { * @brief Collects lists of equality predicate literals in the AST expression, one list per input * table column. This is used in row group filtering based on bloom filters. */ -class equality_literals_collector : public ast::detail::expression_transformer { +class equality_literals_collector : public combined_expression_converter { public: - equality_literals_collector() = default; - equality_literals_collector(ast::expression const& expr, cudf::size_type num_input_columns) - : _num_input_columns{num_input_columns} { + _num_input_columns = num_input_columns; _equality_literals.resize(_num_input_columns); expr.accept(*this); } - /** - * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) - */ - std::reference_wrapper visit(ast::literal const& expr) override - { - _bloom_filter_expr = std::reference_wrapper(expr); - return expr; - } - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) - */ - std::reference_wrapper visit(ast::column_reference const& expr) override - { - CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, - "BloomfilterAST supports only left table"); - CUDF_EXPECTS(expr.get_column_index() < _num_input_columns, - "Column index cannot be more than number of columns in the table"); - _bloom_filter_expr = std::reference_wrapper(expr); - return expr; - } + // Bring all overloads of `visit` from combined_expression_converter into scope + using combined_expression_converter::visit; /** - * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) + * @brief Delete converted expression getter as no longer needed */ - std::reference_wrapper visit( - ast::column_name_reference const& expr) override - { - CUDF_FAIL("Column name reference is not supported in BloomfilterAST"); - } + [[nodiscard]] std::reference_wrapper get_converted_expr() = delete; /** * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) @@ -237,7 +210,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { _operators.emplace_back(op, new_operands.front()); } } - _bloom_filter_expr = std::reference_wrapper(_operators.back()); + _converted_expr = std::reference_wrapper(_operators.back()); return std::reference_wrapper(_operators.back()); } @@ -250,121 +223,6 @@ class equality_literals_collector : public ast::detail::expression_transformer { { return _equality_literals; } - - protected: - std::vector> visit_operands( - cudf::host_span const> operands) - { - std::vector> transformed_operands; - for (auto const& operand : operands) { - auto const new_operand = operand.get().accept(*this); - transformed_operands.push_back(new_operand); - } - return transformed_operands; - } - std::optional> _bloom_filter_expr; - std::vector> _equality_literals; - std::list _col_ref; - std::list _operators; - size_type _num_input_columns; -}; - -/** - * @brief Converts AST expression to bloom filter membership (BloomfilterAST) expression. - * This is used in row group filtering based on equality predicate. - */ -class bloom_filter_expression_converter : public equality_literals_collector { - public: - bloom_filter_expression_converter( - ast::expression const& expr, - size_type num_input_columns, - std::vector> const& equality_literals) - { - // Set the num columns and copy equality literals - _num_input_columns = num_input_columns; - _equality_literals = equality_literals; - - // Compute and store columns literals offsets - _col_literals_offsets.reserve(_num_input_columns + 1); - _col_literals_offsets.emplace_back(0); - - std::transform(equality_literals.begin(), - equality_literals.end(), - std::back_inserter(_col_literals_offsets), - [&](auto const& col_literal_map) { - return _col_literals_offsets.back() + - static_cast(col_literal_map.size()); - }); - - // Add this visitor - expr.accept(*this); - } - - /** - * @brief Delete equality literals getter as no longer needed - */ - [[nodiscard]] std::vector> get_equality_literals() = delete; - - // Bring all overloads of `visit` from equality_predicate_collector into scope - using equality_literals_collector::visit; - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) - */ - std::reference_wrapper visit(ast::operation const& expr) override - { - using cudf::ast::ast_operator; - auto const operands = expr.get_operands(); - auto const op = expr.get_operator(); - - if (auto* v = dynamic_cast(&operands[0].get())) { - // First operand should be column reference, second should be literal. - CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, - "Only binary operations are supported on column reference"); - CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, - "Second operand of binary operation with column reference must be a literal"); - v->accept(*this); - - if (op == ast_operator::EQUAL) { - // Search the literal in this input column's equality literals list and add to the offset. - auto const col_idx = v->get_column_index(); - auto const& equality_literals = _equality_literals[col_idx]; - auto col_literal_offset = _col_literals_offsets[col_idx]; - auto const literal_iter = std::find(equality_literals.cbegin(), - equality_literals.cend(), - dynamic_cast(&operands[1].get())); - CUDF_EXPECTS(literal_iter != equality_literals.end(), "Could not find the literal ptr"); - col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); - - // Evaluate boolean is_true(value) expression as NOT(NOT(value)) - auto const& value = _col_ref.emplace_back(col_literal_offset); - auto const& op = _operators.emplace_back(ast_operator::NOT, value); - _operators.emplace_back(ast_operator::NOT, op); - } - } else { - auto new_operands = visit_operands(operands); - if (cudf::ast::detail::ast_operator_arity(op) == 2) { - _operators.emplace_back(op, new_operands.front(), new_operands.back()); - } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { - _operators.emplace_back(op, new_operands.front()); - } - } - _bloom_filter_expr = std::reference_wrapper(_operators.back()); - return std::reference_wrapper(_operators.back()); - } - - /** - * @brief Returns the AST to apply on bloom filters mmebership. - * - * @return AST operation expression - */ - [[nodiscard]] std::reference_wrapper get_bloom_filter_expr() const - { - return _bloom_filter_expr.value().get(); - } - - private: - std::vector _col_literals_offsets; }; /** @@ -486,12 +344,12 @@ std::vector aggregate_reader_metadata::read_bloom_filters( host_span const> sources, host_span const> row_group_indices, host_span column_schemas, - size_type num_row_groups, + size_type total_row_groups, rmm::cuda_stream_view stream) const { // Descriptors for all the chunks that make up the selected columns auto const num_input_columns = column_schemas.size(); - auto const num_chunks = num_row_groups * num_input_columns; + auto const num_chunks = total_row_groups * num_input_columns; // Association between each column chunk and its source std::vector chunk_source_map(num_chunks); @@ -577,12 +435,14 @@ std::vector aggregate_reader_metadata::get_parquet_types( return parquet_types; } -std::optional>> aggregate_reader_metadata::apply_bloom_filters( +std::pair>, std::vector>> +aggregate_reader_metadata::apply_bloom_filters( host_span const> sources, host_span const> input_row_group_indices, host_span output_dtypes, host_span output_column_schemas, std::reference_wrapper filter, + size_t total_row_groups, rmm::cuda_stream_view stream) const { // Number of input table columns @@ -590,17 +450,6 @@ std::optional>> aggregate_reader_metadata::ap auto mr = cudf::get_current_device_resource_ref(); - // Total number of row groups to process. - auto const num_row_groups = std::accumulate( - input_row_group_indices.begin(), - input_row_group_indices.end(), - size_t{0}, - [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); - - // Check if we have less than 2B total row groups. - CUDF_EXPECTS(num_row_groups <= std::numeric_limits::max(), - "Total number of row groups exceed the size_type's limit"); - // Collect equality literals for each input table column auto const equality_literals = equality_literals_collector{filter.get(), num_input_columns}.get_equality_literals(); @@ -616,16 +465,19 @@ std::optional>> aggregate_reader_metadata::ap } }); + // Read bloom filters and add literal membership columns. + std::vector> bloom_filter_membership_columns; + // Return early if no column with equality predicate(s) - if (equality_col_schemas.empty()) { return std::nullopt; } + if (equality_col_schemas.empty()) { return {}; } // Read a vector of bloom filter bitset device buffers for all column with equality // predicate(s) across all row groups auto bloom_filter_data = read_bloom_filters( - sources, input_row_group_indices, equality_col_schemas, num_row_groups, stream); + sources, input_row_group_indices, equality_col_schemas, total_row_groups, stream); // No bloom filter buffers, return the original row group indices - if (bloom_filter_data.empty()) { return std::nullopt; } + if (bloom_filter_data.empty()) { return {}; } // Get parquet types for the predicate columns auto const parquet_types = get_parquet_types(input_row_group_indices, equality_col_schemas); @@ -648,45 +500,33 @@ std::optional>> aggregate_reader_metadata::ap // Create a bloom filter query table caster. bloom_filter_caster bloom_filter_col{ - bloom_filter_spans, parquet_types, num_row_groups, equality_col_schemas.size()}; + bloom_filter_spans, parquet_types, total_row_groups, equality_col_schemas.size()}; // Converts bloom filter membership for equality predicate columns to a table // containing a column for each `col[i] == literal` predicate to be evaluated. // The table contains #sources * #column_chunks_per_src rows. - std::vector> columns; size_t equality_col_idx = 0; - std::for_each(thrust::make_counting_iterator(0), - thrust::make_counting_iterator(output_dtypes.size()), - [&](auto input_col_idx) { - auto const& dtype = output_dtypes[input_col_idx]; + std::for_each( + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(output_dtypes.size()), + [&](auto input_col_idx) { + auto const& dtype = output_dtypes[input_col_idx]; - // Skip if no equality literals for this column - if (equality_literals[input_col_idx].empty()) { return; } + // Skip if no equality literals for this column + if (equality_literals[input_col_idx].empty()) { return; } - // Skip if non-comparable (compound) type except string - if (cudf::is_compound(dtype) and dtype.id() != cudf::type_id::STRING) { return; } + // Skip if non-comparable (compound) type except string + if (cudf::is_compound(dtype) and dtype.id() != cudf::type_id::STRING) { return; } - // Add a column for all literals associated with an equality column - for (auto const& literal : equality_literals[input_col_idx]) { - columns.emplace_back(cudf::type_dispatcher( - dtype, bloom_filter_col, equality_col_idx, dtype, literal, stream, mr)); - } - equality_col_idx++; - }); - auto bloom_filter_membership_table = cudf::table(std::move(columns)); - - // Convert AST to BloomfilterAST expression with reference to bloom filter membership - // in above `bloom_filter_membership_table` - bloom_filter_expression_converter bloom_filter_expr{ - filter.get(), num_input_columns, equality_literals}; - - // Filter bloom filter membership table with the BloomfilterAST expression and collect - // filtered row group indices - return collect_filtered_row_group_indices(bloom_filter_membership_table, - bloom_filter_expr.get_bloom_filter_expr(), - input_row_group_indices, - stream, - mr); + // Add a column for all literals associated with an equality column + for (auto const& literal : equality_literals[input_col_idx]) { + bloom_filter_membership_columns.emplace_back(cudf::type_dispatcher( + dtype, bloom_filter_col, equality_col_idx, dtype, literal, stream, mr)); + } + equality_col_idx++; + }); + + return {std::move(bloom_filter_membership_columns), std::move(equality_literals)}; } } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index c2aa012feb5..5ff8645b569 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -246,149 +246,6 @@ struct stats_caster { } }; -/** - * @brief Converts AST expression to StatsAST for comparing with column statistics - * This is used in row group filtering based on predicate. - * statistics min value of a column is referenced by column_index*2 - * statistics max value of a column is referenced by column_index*2+1 - * - */ -class stats_expression_converter : public ast::detail::expression_transformer { - public: - stats_expression_converter(ast::expression const& expr, size_type const& num_columns) - : _num_columns{num_columns} - { - expr.accept(*this); - } - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) - */ - std::reference_wrapper visit(ast::literal const& expr) override - { - _stats_expr = std::reference_wrapper(expr); - return expr; - } - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) - */ - std::reference_wrapper visit(ast::column_reference const& expr) override - { - CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, - "Statistics AST supports only left table"); - CUDF_EXPECTS(expr.get_column_index() < _num_columns, - "Column index cannot be more than number of columns in the table"); - _stats_expr = std::reference_wrapper(expr); - return expr; - } - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) - */ - std::reference_wrapper visit( - ast::column_name_reference const& expr) override - { - CUDF_FAIL("Column name reference is not supported in statistics AST"); - } - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) - */ - std::reference_wrapper visit(ast::operation const& expr) override - { - using cudf::ast::ast_operator; - auto const operands = expr.get_operands(); - auto const op = expr.get_operator(); - - if (auto* v = dynamic_cast(&operands[0].get())) { - // First operand should be column reference, second should be literal. - CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, - "Only binary operations are supported on column reference"); - CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, - "Second operand of binary operation with column reference must be a literal"); - v->accept(*this); - auto const col_index = v->get_column_index(); - switch (op) { - /* transform to stats conditions. op(col, literal) - col1 == val --> vmin <= val && vmax >= val - col1 != val --> !(vmin == val && vmax == val) - col1 > val --> vmax > val - col1 < val --> vmin < val - col1 >= val --> vmax >= val - col1 <= val --> vmin <= val - */ - case ast_operator::EQUAL: { - auto const& vmin = _col_ref.emplace_back(col_index * 2); - auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); - auto const& op1 = - _operators.emplace_back(ast_operator::LESS_EQUAL, vmin, operands[1].get()); - auto const& op2 = - _operators.emplace_back(ast_operator::GREATER_EQUAL, vmax, operands[1].get()); - _operators.emplace_back(ast::ast_operator::LOGICAL_AND, op1, op2); - break; - } - case ast_operator::NOT_EQUAL: { - auto const& vmin = _col_ref.emplace_back(col_index * 2); - auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); - auto const& op1 = _operators.emplace_back(ast_operator::NOT_EQUAL, vmin, vmax); - auto const& op2 = - _operators.emplace_back(ast_operator::NOT_EQUAL, vmax, operands[1].get()); - _operators.emplace_back(ast_operator::LOGICAL_OR, op1, op2); - break; - } - case ast_operator::LESS: [[fallthrough]]; - case ast_operator::LESS_EQUAL: { - auto const& vmin = _col_ref.emplace_back(col_index * 2); - _operators.emplace_back(op, vmin, operands[1].get()); - break; - } - case ast_operator::GREATER: [[fallthrough]]; - case ast_operator::GREATER_EQUAL: { - auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); - _operators.emplace_back(op, vmax, operands[1].get()); - break; - } - default: CUDF_FAIL("Unsupported operation in Statistics AST"); - }; - } else { - auto new_operands = visit_operands(operands); - if (cudf::ast::detail::ast_operator_arity(op) == 2) { - _operators.emplace_back(op, new_operands.front(), new_operands.back()); - } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { - _operators.emplace_back(op, new_operands.front()); - } - } - _stats_expr = std::reference_wrapper(_operators.back()); - return std::reference_wrapper(_operators.back()); - } - - /** - * @brief Returns the AST to apply on Column chunk statistics. - * - * @return AST operation expression - */ - [[nodiscard]] std::reference_wrapper get_stats_expr() const - { - return _stats_expr.value().get(); - } - - private: - std::vector> visit_operands( - cudf::host_span const> operands) - { - std::vector> transformed_operands; - for (auto const& operand : operands) { - auto const new_operand = operand.get().accept(*this); - transformed_operands.push_back(new_operand); - } - return transformed_operands; - } - std::optional> _stats_expr; - size_type _num_columns; - std::list _col_ref; - std::list _operators; -}; } // namespace std::optional>> aggregate_reader_metadata::filter_row_groups( @@ -399,6 +256,9 @@ std::optional>> aggregate_reader_metadata::fi std::reference_wrapper filter, rmm::cuda_stream_view stream) const { + // Number of input table columns + auto const num_input_columns = static_cast(output_dtypes.size()); + auto mr = cudf::get_current_device_resource_ref(); // Create row group indices. std::vector> all_row_group_indices; @@ -449,34 +309,180 @@ std::optional>> aggregate_reader_metadata::fi columns.push_back(std::move(min_col)); columns.push_back(std::move(max_col)); } - auto stats_table = cudf::table(std::move(columns)); - // Converts AST to StatsAST with reference to min, max columns in above `stats_table`. - stats_expression_converter const stats_expr{filter.get(), - static_cast(output_dtypes.size())}; - auto stats_ast = stats_expr.get_stats_expr(); - auto predicate_col = cudf::detail::compute_column(stats_table, stats_ast.get(), stream, mr); - auto predicate = predicate_col->view(); - CUDF_EXPECTS(predicate.type().id() == cudf::type_id::BOOL8, - "Filter expression must return a boolean column"); + // Apply bloom filter membership to row groups for each equality predicate column, literal pair + // and get corresponding boolean columns. + auto [bloom_filter_membership_cols, equality_literals] = + apply_bloom_filters(sources, + input_row_group_indices, + output_dtypes, + output_column_schemas, + filter, + total_row_groups, + stream); + + // Check if we have any bloom filter membership columns + auto const has_bloom_filters = not bloom_filter_membership_cols.empty(); + + // Append bloom filter membership columns to stats columns to get combined columns if needed + if (has_bloom_filters) { + columns.insert(columns.end(), + std::make_move_iterator(bloom_filter_membership_cols.begin()), + std::make_move_iterator(bloom_filter_membership_cols.end())); + } - // Filter stats table with StatsAST expression and collect filtered row group indices - auto const filtered_row_group_indices = collect_filtered_row_group_indices( - stats_table, stats_expr.get_stats_expr(), input_row_group_indices, stream, mr); + auto combined_table = cudf::table(std::move(columns)); - // Span on row groups to apply bloom filtering on. - auto const bloom_filter_input_row_groups = - filtered_row_group_indices.has_value() - ? host_span const>(filtered_row_group_indices.value()) - : input_row_group_indices; + // Convert AST to a CombinedAST (StatsAST and BloomfilterAST) expression + combined_expression_converter combined_expr{ + filter.get(), num_input_columns, equality_literals, has_bloom_filters}; + + // Filter combined table with the AST expression and collect filtered row group indices + return collect_filtered_row_group_indices( + combined_table, combined_expr.get_converted_expr(), input_row_group_indices, stream, mr); +} + +// Convert AST expression to a CombinedAST (StatsAST and BloomfilterAST) +combined_expression_converter::combined_expression_converter( + ast::expression const& expr, + size_type num_input_columns, + std::vector> const& equality_literals, + bool has_bloom_filters) + : _has_bloom_filters{has_bloom_filters} +{ + // Set the num columns and copy equality literals + _num_input_columns = num_input_columns; + _equality_literals = equality_literals; + + // Compute and store columns literals offsets + _col_literals_offsets.reserve(_num_input_columns + 1); + _col_literals_offsets.emplace_back(0); + + std::transform(equality_literals.begin(), + equality_literals.end(), + std::back_inserter(_col_literals_offsets), + [&](auto const& col_literal_map) { + return _col_literals_offsets.back() + + static_cast(col_literal_map.size()); + }); + + // Add this visitor + expr.accept(*this); +} + +std::reference_wrapper combined_expression_converter::visit( + ast::literal const& expr) +{ + _converted_expr = std::reference_wrapper(expr); + return expr; +} + +std::reference_wrapper combined_expression_converter::visit( + ast::column_reference const& expr) +{ + CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, + "CombinedAST (Stats and Bloomfilter) supports only left table"); + CUDF_EXPECTS(expr.get_column_index() < _num_input_columns, + "Column index cannot be more than number of columns in the table"); + _converted_expr = std::reference_wrapper(expr); + return expr; +} - // Apply bloom filtering on the bloom filter input row groups - auto const bloom_filtered_row_groups = apply_bloom_filters( - sources, bloom_filter_input_row_groups, output_dtypes, output_column_schemas, filter, stream); +std::reference_wrapper combined_expression_converter::visit( + ast::operation const& expr) +{ + using cudf::ast::ast_operator; + auto const operands = expr.get_operands(); + auto const op = expr.get_operator(); + + if (auto* v = dynamic_cast(&operands[0].get())) { + // First operand should be column reference, second should be literal. + CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, + "Only binary operations are supported on column reference"); + CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, + "Second operand of binary operation with column reference must be a literal"); + v->accept(*this); + auto const col_index = v->get_column_index(); + switch (op) { + /* transform to stats conditions. op(col, literal) + col1 == val --> vmin <= val && vmax >= val && bloom_filter[col1, val].contains(val) + col1 != val --> !(vmin == val && vmax == val) + col1 > val --> vmax > val + col1 < val --> vmin < val + col1 >= val --> vmax >= val + col1 <= val --> vmin <= val + */ + case ast_operator::EQUAL: { + auto const& vmin = _col_ref.emplace_back(col_index * 2); + auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); + auto const& op1 = + _operators.emplace_back(ast_operator::LESS_EQUAL, vmin, operands[1].get()); + auto const& op2 = + _operators.emplace_back(ast_operator::GREATER_EQUAL, vmax, operands[1].get()); + _operators.emplace_back(ast::ast_operator::LOGICAL_AND, op1, op2); + + // Use this input column's bloom filter membership column as well if available. + if (_has_bloom_filters) { + auto const& equality_literals = _equality_literals[col_index]; + auto col_literal_offset = (_num_input_columns * 2) + _col_literals_offsets[col_index]; + auto const literal_iter = + std::find(equality_literals.cbegin(), + equality_literals.cend(), + dynamic_cast(&operands[1].get())); + CUDF_EXPECTS(literal_iter != equality_literals.end(), "Could not find the literal ptr"); + col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); + + // Evaluate boolean is_true(value) expression as NOT(NOT(value)) + auto const& value = _col_ref.emplace_back(col_literal_offset); + auto const& op = _operators.emplace_back(ast_operator::NOT, value); + _operators.emplace_back(ast_operator::NOT, op); + } + break; + } + case ast_operator::NOT_EQUAL: { + auto const& vmin = _col_ref.emplace_back(col_index * 2); + auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); + auto const& op1 = _operators.emplace_back(ast_operator::NOT_EQUAL, vmin, vmax); + auto const& op2 = _operators.emplace_back(ast_operator::NOT_EQUAL, vmax, operands[1].get()); + _operators.emplace_back(ast_operator::LOGICAL_OR, op1, op2); + break; + } + case ast_operator::LESS: [[fallthrough]]; + case ast_operator::LESS_EQUAL: { + auto const& vmin = _col_ref.emplace_back(col_index * 2); + _operators.emplace_back(op, vmin, operands[1].get()); + break; + } + case ast_operator::GREATER: [[fallthrough]]; + case ast_operator::GREATER_EQUAL: { + auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); + _operators.emplace_back(op, vmax, operands[1].get()); + break; + } + default: CUDF_FAIL("Unsupported operation in CombinedAST"); + }; + } else { + auto new_operands = visit_operands(operands); + if (cudf::ast::detail::ast_operator_arity(op) == 2) { + _operators.emplace_back(op, new_operands.front(), new_operands.back()); + } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { + _operators.emplace_back(op, new_operands.front()); + } + } + _converted_expr = std::reference_wrapper(_operators.back()); + return std::reference_wrapper(_operators.back()); +} - // Return bloom filtered row group indices iff collected - return bloom_filtered_row_groups.has_value() ? bloom_filtered_row_groups - : filtered_row_group_indices; +std::vector> +combined_expression_converter::visit_operands( + cudf::host_span const> operands) +{ + std::vector> transformed_operands; + for (auto const& operand : operands) { + auto const new_operand = operand.get().accept(*this); + transformed_operands.push_back(new_operand); + } + return transformed_operands; } // convert column named expression to column index reference expression @@ -497,14 +503,14 @@ named_to_reference_converter::named_to_reference_converter( std::reference_wrapper named_to_reference_converter::visit( ast::literal const& expr) { - _stats_expr = std::reference_wrapper(expr); + _converted_expr = std::reference_wrapper(expr); return expr; } std::reference_wrapper named_to_reference_converter::visit( ast::column_reference const& expr) { - _stats_expr = std::reference_wrapper(expr); + _converted_expr = std::reference_wrapper(expr); return expr; } @@ -518,7 +524,7 @@ std::reference_wrapper named_to_reference_converter::visi } auto col_index = col_index_it->second; _col_ref.emplace_back(col_index); - _stats_expr = std::reference_wrapper(_col_ref.back()); + _converted_expr = std::reference_wrapper(_col_ref.back()); return std::reference_wrapper(_col_ref.back()); } @@ -533,7 +539,7 @@ std::reference_wrapper named_to_reference_converter::visi } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { _operators.emplace_back(op, new_operands.front()); } - _stats_expr = std::reference_wrapper(_operators.back()); + _converted_expr = std::reference_wrapper(_operators.back()); return std::reference_wrapper(_operators.back()); } diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 2e3e6c6b6dd..859d9aadc94 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -372,24 +372,28 @@ class aggregate_reader_metadata { rmm::cuda_stream_view stream) const; /** - * @brief Filters the row groups using bloom filters + * @brief Computes bloom filter membership for the row groups based on predicate filter * * @param sources Dataset sources * @param row_group_indices Lists of input row groups to read, one per source * @param output_dtypes Datatypes of of output columns * @param output_column_schemas schema indices of output columns * @param filter AST expression to filter row groups based on bloom filter membership + * @param total_row_groups Total number of row groups across all input sources. * @param stream CUDA stream used for device memory operations and kernel launches * - * @return Filtered row group indices, if any is filtered + * @return A pair of list of bloom filter membership columns and a list of pointers to (equality + * predicate) literals associated with each input column. */ - [[nodiscard]] std::optional>> apply_bloom_filters( - host_span const> sources, - host_span const> input_row_group_indices, - host_span output_dtypes, - host_span output_column_schemas, - std::reference_wrapper filter, - rmm::cuda_stream_view stream) const; + [[nodiscard]] std::pair>, + std::vector>> + apply_bloom_filters(host_span const> sources, + host_span const> input_row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + std::reference_wrapper filter, + size_t total_row_groups, + rmm::cuda_stream_view stream) const; /** * @brief Filters and reduces down to a selection of row groups @@ -441,6 +445,71 @@ class aggregate_reader_metadata { type_id timestamp_type_id); }; +/** + * @brief Converts AST expression to a CombinedAST (StatsAST and BloomfilterAST) for comparing with + * column statistics and testing bloom filter membership altogether. This is used in row group + * filtering based on predicate. + * + * statistics min value of a column is referenced by column_index*2 + * statistics max value of a column is referenced by column_index*2+1 + * bloom filter membership of each (column, literal) pair is appended to the end of stats. + * + */ +class combined_expression_converter : public ast::detail::expression_transformer { + public: + combined_expression_converter() = default; + combined_expression_converter(ast::expression const& expr, + size_type num_input_columns, + std::vector> const& equality_literals, + bool has_bloom_filters); + /** + * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) + */ + std::reference_wrapper visit(ast::literal const& expr) override; + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) + */ + std::reference_wrapper visit(ast::column_reference const& expr) override; + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) + */ + std::reference_wrapper visit( + ast::column_name_reference const& expr) override + { + CUDF_FAIL("Column name reference is not supported in CombinedAST"); + } + /** + * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + */ + std::reference_wrapper visit(ast::operation const& expr) override; + /** + * @brief Returns the AST to apply Column chunk statistics and bloom filter membership + * + * @return AST operation expression + */ + [[nodiscard]] std::reference_wrapper get_converted_expr() const + { + return _converted_expr.value().get(); + } + + protected: + std::vector> visit_operands( + cudf::host_span const> operands); + + std::optional> _converted_expr; + size_type _num_columns; + std::list _col_ref; + std::list _operators; + std::vector> _equality_literals; + size_type _num_input_columns; + + private: + bool _has_bloom_filters; + std::vector _col_literals_offsets; +}; + /** * @brief Converts named columns to index reference columns * @@ -469,14 +538,14 @@ class named_to_reference_converter : public ast::detail::expression_transformer std::reference_wrapper visit(ast::operation const& expr) override; /** - * @brief Returns the AST to apply on Column chunk statistics + * @brief Returns the converted AST expression * * @return AST operation expression */ [[nodiscard]] std::optional> get_converted_expr() const { - return _stats_expr; + return _converted_expr; } private: @@ -484,7 +553,7 @@ class named_to_reference_converter : public ast::detail::expression_transformer cudf::host_span const> operands); std::unordered_map column_name_to_index; - std::optional> _stats_expr; + std::optional> _converted_expr; // Using std::list or std::deque to avoid reference invalidation std::list _col_ref; std::list _operators; diff --git a/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_chunk_stats.snappy.parquet b/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_100_chunk_stats.snappy.parquet index e1dad1ef8b59edea48aa8c5c41029d9a70babe8f..7dc2cee21ae5e3414f4f23edd0dc31ad03a3976c 100644 GIT binary patch literal 37036 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 literal 12413 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�<}b5g6F)7hn=lI1ib` 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 j#=DSFxFSOiDc@*yN_?Y0W{;;Ubpehgmi@ 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 Do`x(* From 88bf4913713b4ae73c4b45110b976220eca7af26 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 05:11:18 +0000 Subject: [PATCH 50/82] Minor, add const --- cpp/src/io/parquet/bloom_filter_reader.cu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 5054051b042..7f7db91d1c4 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -499,7 +499,7 @@ aggregate_reader_metadata::apply_bloom_filters( cudf::detail::make_device_uvector_async(h_bloom_filter_spans, stream, mr); // Create a bloom filter query table caster. - bloom_filter_caster bloom_filter_col{ + bloom_filter_caster const bloom_filter_col{ bloom_filter_spans, parquet_types, total_row_groups, equality_col_schemas.size()}; // Converts bloom filter membership for equality predicate columns to a table From 9ca42c62eab9acdb52148e479e14516a10200cf9 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 05:42:55 +0000 Subject: [PATCH 51/82] Move bloom filter test to parquet test --- cpp/tests/CMakeLists.txt | 6 +++--- .../parquet_bloom_filter_test.cu} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename cpp/tests/{hashing/bloom_filter_test.cu => io/parquet_bloom_filter_test.cu} (100%) diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 94a5063d150..2e30e390032 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -182,7 +182,6 @@ ConfigureTest(DATETIME_OPS_TEST datetime/datetime_ops_test.cpp) # * hashing tests --------------------------------------------------------------------------------- ConfigureTest( HASHING_TEST - hashing/bloom_filter_test.cu hashing/md5_test.cpp hashing/murmurhash3_x86_32_test.cpp hashing/murmurhash3_x64_128_test.cpp @@ -315,14 +314,15 @@ ConfigureTest( ) ConfigureTest( PARQUET_TEST - io/parquet_test.cpp + io/parquet_bloom_filter_test.cu io/parquet_chunked_reader_test.cu io/parquet_chunked_writer_test.cpp io/parquet_common.cpp io/parquet_misc_test.cpp io/parquet_reader_test.cpp - io/parquet_writer_test.cpp + io/parquet_test.cpp io/parquet_v2_test.cpp + io/parquet_writer_test.cpp GPUS 1 PERCENT 30 ) diff --git a/cpp/tests/hashing/bloom_filter_test.cu b/cpp/tests/io/parquet_bloom_filter_test.cu similarity index 100% rename from cpp/tests/hashing/bloom_filter_test.cu rename to cpp/tests/io/parquet_bloom_filter_test.cu From 84c24c16c8374c9951827b0b5890e5c0e72e8901 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 06:04:27 +0000 Subject: [PATCH 52/82] Minor updates --- cpp/tests/io/parquet_bloom_filter_test.cu | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/cpp/tests/io/parquet_bloom_filter_test.cu b/cpp/tests/io/parquet_bloom_filter_test.cu index 9ca76c085b6..c290de4568a 100644 --- a/cpp/tests/io/parquet_bloom_filter_test.cu +++ b/cpp/tests/io/parquet_bloom_filter_test.cu @@ -31,9 +31,9 @@ using StringType = cudf::string_view; -class BloomFilterTest : public cudf::test::BaseFixture {}; +class ParquetBloomFilterTest : public cudf::test::BaseFixture {}; -TEST_F(BloomFilterTest, TestStrings) +TEST_F(ParquetBloomFilterTest, TestStrings) { using key_type = StringType; using hasher_type = cudf::hashing::detail::XXHash_64; @@ -43,8 +43,8 @@ TEST_F(BloomFilterTest, TestStrings) std::size_t constexpr num_filter_blocks = 4; auto stream = cudf::get_default_stream(); - // strings data - auto data = cudf::test::strings_column_wrapper( + // strings keys to insert + auto keys = cudf::test::strings_column_wrapper( {"seventh", "fifteenth", "second", "tenth", "fifth", "first", "seventh", "tenth", "ninth", "ninth", "seventeenth", "eighteenth", "thirteenth", "fifth", "fourth", "twelfth", "second", "second", @@ -54,7 +54,8 @@ TEST_F(BloomFilterTest, TestStrings) "seventh", "tenth", "fourteenth", "first", "fifth", "fifth", "tenth", "thirteenth", "fourteenth", "third", "third", "sixth", "first", "third"}); - auto d_column = cudf::column_device_view::create(data); + + auto d_keys = cudf::column_device_view::create(keys); // Spawn a bloom filter cuco::bloom_filterbegin(), d_column->end(), stream); + filter.add(d_keys->begin(), d_keys->end(), stream); // Number of words in the filter cudf::size_type const num_words = filter.block_extent() * filter.words_per_block; - auto const output = cudf::column_view{ + // Filter bitset as a column + auto const bitset = cudf::column_view{ cudf::data_type{cudf::type_id::UINT32}, num_words, filter.data(), nullptr, 0, 0, {}}; - // Expected filter bitset words computed using Arrow implementation here: + // Expected filter bitset words computed using Arrow's implementation here: // https://godbolt.org/z/oKfqcPWbY auto expected = cudf::test::fixed_width_column_wrapper( {4194306U, 4194305U, 2359296U, 1073774592U, 524544U, 1024U, 268443648U, @@ -87,5 +89,5 @@ TEST_F(BloomFilterTest, TestStrings) 2216738864U, 587333888U, 4219272U, 873463873U}); // Check the bitset for equality - CUDF_TEST_EXPECT_COLUMNS_EQUAL(output, expected); + CUDF_TEST_EXPECT_COLUMNS_EQUAL(bitset, expected); } From 0c0503174b78220b2e99ac71f242cdd8caa1fced Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 17:57:54 +0000 Subject: [PATCH 53/82] Minor --- cpp/src/io/parquet/bloom_filter_reader.cu | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 7f7db91d1c4..9bebd6bcabf 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -485,13 +485,12 @@ aggregate_reader_metadata::apply_bloom_filters( // Create spans from bloom filter bitset buffers std::vector> h_bloom_filter_spans; h_bloom_filter_spans.reserve(bloom_filter_data.size()); - std::transform(thrust::make_counting_iterator(0), - thrust::make_counting_iterator(bloom_filter_data.size()), + std::transform(bloom_filter_data.begin(), + bloom_filter_data.end(), std::back_inserter(h_bloom_filter_spans), - [&](auto const filter_idx) { + [&](auto& buffer) { return cudf::device_span{ - static_cast(bloom_filter_data[filter_idx].data()), - bloom_filter_data[filter_idx].size()}; + static_cast(buffer.data()), buffer.size()}; }); // Copy bloom filter bitset spans to device From 09560c56cb84c6eac103e34a713ed8bb2f739a7a Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 18:27:13 +0000 Subject: [PATCH 54/82] Logical and between bloom filter and stats --- cpp/src/io/parquet/predicate_pushdown.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 5ff8645b569..57c168d4c56 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -419,7 +419,7 @@ std::reference_wrapper combined_expression_converter::vis _operators.emplace_back(ast_operator::LESS_EQUAL, vmin, operands[1].get()); auto const& op2 = _operators.emplace_back(ast_operator::GREATER_EQUAL, vmax, operands[1].get()); - _operators.emplace_back(ast::ast_operator::LOGICAL_AND, op1, op2); + auto const& stats_op = _operators.emplace_back(ast::ast_operator::LOGICAL_AND, op1, op2); // Use this input column's bloom filter membership column as well if available. if (_has_bloom_filters) { @@ -433,9 +433,12 @@ std::reference_wrapper combined_expression_converter::vis col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); // Evaluate boolean is_true(value) expression as NOT(NOT(value)) - auto const& value = _col_ref.emplace_back(col_literal_offset); - auto const& op = _operators.emplace_back(ast_operator::NOT, value); - _operators.emplace_back(ast_operator::NOT, op); + auto const& value = _col_ref.emplace_back(col_literal_offset); + auto const& op = _operators.emplace_back(ast_operator::NOT, value); + auto const& bloom_filter_op = _operators.emplace_back(ast_operator::NOT, op); + + // Logical and between stats and bloom filter operators + _operators.emplace_back(ast_operator::LOGICAL_AND, stats_op, bloom_filter_op); } break; } From 21f4412d7ebcce746fac06b24f49325874a93daa Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 19:14:56 +0000 Subject: [PATCH 55/82] Revert merging converted AST tables. --- cpp/src/io/parquet/bloom_filter_reader.cu | 179 ++++++++++- cpp/src/io/parquet/predicate_pushdown.cpp | 353 ++++++++++----------- cpp/src/io/parquet/reader_impl_helpers.hpp | 89 +----- 3 files changed, 351 insertions(+), 270 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 9bebd6bcabf..d01ff0c1c63 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -162,22 +162,47 @@ struct bloom_filter_caster { * @brief Collects lists of equality predicate literals in the AST expression, one list per input * table column. This is used in row group filtering based on bloom filters. */ -class equality_literals_collector : public combined_expression_converter { +class equality_literals_collector : public ast::detail::expression_transformer { public: + equality_literals_collector() = default; + equality_literals_collector(ast::expression const& expr, cudf::size_type num_input_columns) + : _num_input_columns{num_input_columns} { - _num_input_columns = num_input_columns; _equality_literals.resize(_num_input_columns); expr.accept(*this); } - // Bring all overloads of `visit` from combined_expression_converter into scope - using combined_expression_converter::visit; + /** + * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) + */ + std::reference_wrapper visit(ast::literal const& expr) override + { + _bloom_filter_expr = std::reference_wrapper(expr); + return expr; + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) + */ + std::reference_wrapper visit(ast::column_reference const& expr) override + { + CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, + "BloomfilterAST supports only left table"); + CUDF_EXPECTS(expr.get_column_index() < _num_input_columns, + "Column index cannot be more than number of columns in the table"); + _bloom_filter_expr = std::reference_wrapper(expr); + return expr; + } /** - * @brief Delete converted expression getter as no longer needed + * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) */ - [[nodiscard]] std::reference_wrapper get_converted_expr() = delete; + std::reference_wrapper visit( + ast::column_name_reference const& expr) override + { + CUDF_FAIL("Column name reference is not supported in BloomfilterAST"); + } /** * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) @@ -210,7 +235,7 @@ class equality_literals_collector : public combined_expression_converter { _operators.emplace_back(op, new_operands.front()); } } - _converted_expr = std::reference_wrapper(_operators.back()); + _bloom_filter_expr = std::reference_wrapper(_operators.back()); return std::reference_wrapper(_operators.back()); } @@ -223,6 +248,121 @@ class equality_literals_collector : public combined_expression_converter { { return _equality_literals; } + + protected: + std::vector> visit_operands( + cudf::host_span const> operands) + { + std::vector> transformed_operands; + for (auto const& operand : operands) { + auto const new_operand = operand.get().accept(*this); + transformed_operands.push_back(new_operand); + } + return transformed_operands; + } + std::optional> _bloom_filter_expr; + std::vector> _equality_literals; + std::list _col_ref; + std::list _operators; + size_type _num_input_columns; +}; + +/** + * @brief Converts AST expression to bloom filter membership (BloomfilterAST) expression. + * This is used in row group filtering based on equality predicate. + */ +class bloom_filter_expression_converter : public equality_literals_collector { + public: + bloom_filter_expression_converter( + ast::expression const& expr, + size_type num_input_columns, + std::vector> const& equality_literals) + { + // Set the num columns and copy equality literals + _num_input_columns = num_input_columns; + _equality_literals = equality_literals; + + // Compute and store columns literals offsets + _col_literals_offsets.reserve(_num_input_columns + 1); + _col_literals_offsets.emplace_back(0); + + std::transform(equality_literals.begin(), + equality_literals.end(), + std::back_inserter(_col_literals_offsets), + [&](auto const& col_literal_map) { + return _col_literals_offsets.back() + + static_cast(col_literal_map.size()); + }); + + // Add this visitor + expr.accept(*this); + } + + /** + * @brief Delete equality literals getter as no longer needed + */ + [[nodiscard]] std::vector> get_equality_literals() = delete; + + // Bring all overloads of `visit` from equality_predicate_collector into scope + using equality_literals_collector::visit; + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + */ + std::reference_wrapper visit(ast::operation const& expr) override + { + using cudf::ast::ast_operator; + auto const operands = expr.get_operands(); + auto const op = expr.get_operator(); + + if (auto* v = dynamic_cast(&operands[0].get())) { + // First operand should be column reference, second should be literal. + CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, + "Only binary operations are supported on column reference"); + CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, + "Second operand of binary operation with column reference must be a literal"); + v->accept(*this); + + if (op == ast_operator::EQUAL) { + // Search the literal in this input column's equality literals list and add to the offset. + auto const col_idx = v->get_column_index(); + auto const& equality_literals = _equality_literals[col_idx]; + auto col_literal_offset = _col_literals_offsets[col_idx]; + auto const literal_iter = std::find(equality_literals.cbegin(), + equality_literals.cend(), + dynamic_cast(&operands[1].get())); + CUDF_EXPECTS(literal_iter != equality_literals.end(), "Could not find the literal ptr"); + col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); + + // Evaluate boolean is_true(value) expression as NOT(NOT(value)) + auto const& value = _col_ref.emplace_back(col_literal_offset); + auto const& op = _operators.emplace_back(ast_operator::NOT, value); + _operators.emplace_back(ast_operator::NOT, op); + } + } else { + auto new_operands = visit_operands(operands); + if (cudf::ast::detail::ast_operator_arity(op) == 2) { + _operators.emplace_back(op, new_operands.front(), new_operands.back()); + } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { + _operators.emplace_back(op, new_operands.front()); + } + } + _bloom_filter_expr = std::reference_wrapper(_operators.back()); + return std::reference_wrapper(_operators.back()); + } + + /** + * @brief Returns the AST to apply on bloom filters mmebership. + * + * @return AST operation expression + */ + [[nodiscard]] std::reference_wrapper get_bloom_filter_expr() const + { + return _bloom_filter_expr.value().get(); + } + + private: + std::vector _col_literals_offsets; }; /** @@ -435,8 +575,7 @@ std::vector aggregate_reader_metadata::get_parquet_types( return parquet_types; } -std::pair>, std::vector>> -aggregate_reader_metadata::apply_bloom_filters( +std::optional>> aggregate_reader_metadata::apply_bloom_filters( host_span const> sources, host_span const> input_row_group_indices, host_span output_dtypes, @@ -469,7 +608,7 @@ aggregate_reader_metadata::apply_bloom_filters( std::vector> bloom_filter_membership_columns; // Return early if no column with equality predicate(s) - if (equality_col_schemas.empty()) { return {}; } + if (equality_col_schemas.empty()) { return std::nullopt; } // Read a vector of bloom filter bitset device buffers for all column with equality // predicate(s) across all row groups @@ -477,12 +616,12 @@ aggregate_reader_metadata::apply_bloom_filters( sources, input_row_group_indices, equality_col_schemas, total_row_groups, stream); // No bloom filter buffers, return the original row group indices - if (bloom_filter_data.empty()) { return {}; } + if (bloom_filter_data.empty()) { return std::nullopt; } // Get parquet types for the predicate columns auto const parquet_types = get_parquet_types(input_row_group_indices, equality_col_schemas); - // Create spans from bloom filter bitset buffers + // Create spans from bloom filter bitset buffers to use in cuco::bloom_filter_ref. std::vector> h_bloom_filter_spans; h_bloom_filter_spans.reserve(bloom_filter_data.size()); std::transform(bloom_filter_data.begin(), @@ -524,8 +663,20 @@ aggregate_reader_metadata::apply_bloom_filters( } equality_col_idx++; }); - - return {std::move(bloom_filter_membership_columns), std::move(equality_literals)}; + auto bloom_filter_membership_table = cudf::table(std::move(bloom_filter_membership_columns)); + + // Convert AST to BloomfilterAST expression with reference to bloom filter membership + // in above `bloom_filter_membership_table` + bloom_filter_expression_converter bloom_filter_expr{ + filter.get(), num_input_columns, equality_literals}; + + // Filter bloom filter membership table with the BloomfilterAST expression and collect + // filtered row group indices + return collect_filtered_row_group_indices(bloom_filter_membership_table, + bloom_filter_expr.get_bloom_filter_expr(), + input_row_group_indices, + stream, + mr); } } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 57c168d4c56..93edbb161ed 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -246,6 +246,150 @@ struct stats_caster { } }; +/** + * @brief Converts AST expression to StatsAST for comparing with column statistics + * This is used in row group filtering based on predicate. + * statistics min value of a column is referenced by column_index*2 + * statistics max value of a column is referenced by column_index*2+1 + * + */ +class stats_expression_converter : public ast::detail::expression_transformer { + public: + stats_expression_converter(ast::expression const& expr, size_type const& num_columns) + : _num_columns{num_columns} + { + expr.accept(*this); + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) + */ + std::reference_wrapper visit(ast::literal const& expr) override + { + _stats_expr = std::reference_wrapper(expr); + return expr; + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) + */ + std::reference_wrapper visit(ast::column_reference const& expr) override + { + CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, + "Statistics AST supports only left table"); + CUDF_EXPECTS(expr.get_column_index() < _num_columns, + "Column index cannot be more than number of columns in the table"); + _stats_expr = std::reference_wrapper(expr); + return expr; + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) + */ + std::reference_wrapper visit( + ast::column_name_reference const& expr) override + { + CUDF_FAIL("Column name reference is not supported in statistics AST"); + } + + /** + * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + */ + std::reference_wrapper visit(ast::operation const& expr) override + { + using cudf::ast::ast_operator; + auto const operands = expr.get_operands(); + auto const op = expr.get_operator(); + + if (auto* v = dynamic_cast(&operands[0].get())) { + // First operand should be column reference, second should be literal. + CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, + "Only binary operations are supported on column reference"); + CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, + "Second operand of binary operation with column reference must be a literal"); + v->accept(*this); + auto const col_index = v->get_column_index(); + switch (op) { + /* transform to stats conditions. op(col, literal) + col1 == val --> vmin <= val && vmax >= val + col1 != val --> !(vmin == val && vmax == val) + col1 > val --> vmax > val + col1 < val --> vmin < val + col1 >= val --> vmax >= val + col1 <= val --> vmin <= val + */ + case ast_operator::EQUAL: { + auto const& vmin = _col_ref.emplace_back(col_index * 2); + auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); + auto const& op1 = + _operators.emplace_back(ast_operator::LESS_EQUAL, vmin, operands[1].get()); + auto const& op2 = + _operators.emplace_back(ast_operator::GREATER_EQUAL, vmax, operands[1].get()); + _operators.emplace_back(ast::ast_operator::LOGICAL_AND, op1, op2); + break; + } + case ast_operator::NOT_EQUAL: { + auto const& vmin = _col_ref.emplace_back(col_index * 2); + auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); + auto const& op1 = _operators.emplace_back(ast_operator::NOT_EQUAL, vmin, vmax); + auto const& op2 = + _operators.emplace_back(ast_operator::NOT_EQUAL, vmax, operands[1].get()); + _operators.emplace_back(ast_operator::LOGICAL_OR, op1, op2); + break; + } + case ast_operator::LESS: [[fallthrough]]; + case ast_operator::LESS_EQUAL: { + auto const& vmin = _col_ref.emplace_back(col_index * 2); + _operators.emplace_back(op, vmin, operands[1].get()); + break; + } + case ast_operator::GREATER: [[fallthrough]]; + case ast_operator::GREATER_EQUAL: { + auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); + _operators.emplace_back(op, vmax, operands[1].get()); + break; + } + default: CUDF_FAIL("Unsupported operation in Statistics AST"); + }; + } else { + auto new_operands = visit_operands(operands); + if (cudf::ast::detail::ast_operator_arity(op) == 2) { + _operators.emplace_back(op, new_operands.front(), new_operands.back()); + } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { + _operators.emplace_back(op, new_operands.front()); + } + } + _stats_expr = std::reference_wrapper(_operators.back()); + return std::reference_wrapper(_operators.back()); + } + + /** + * @brief Returns the AST to apply on Column chunk statistics. + * + * @return AST operation expression + */ + [[nodiscard]] std::reference_wrapper get_stats_expr() const + { + return _stats_expr.value().get(); + } + + private: + std::vector> visit_operands( + cudf::host_span const> operands) + { + std::vector> transformed_operands; + for (auto const& operand : operands) { + auto const new_operand = operand.get().accept(*this); + transformed_operands.push_back(new_operand); + } + return transformed_operands; + } + std::optional> _stats_expr; + size_type _num_columns; + std::list _col_ref; + std::list _operators; +}; + } // namespace std::optional>> aggregate_reader_metadata::filter_row_groups( @@ -256,9 +400,6 @@ std::optional>> aggregate_reader_metadata::fi std::reference_wrapper filter, rmm::cuda_stream_view stream) const { - // Number of input table columns - auto const num_input_columns = static_cast(output_dtypes.size()); - auto mr = cudf::get_current_device_resource_ref(); // Create row group indices. std::vector> all_row_group_indices; @@ -309,183 +450,39 @@ std::optional>> aggregate_reader_metadata::fi columns.push_back(std::move(min_col)); columns.push_back(std::move(max_col)); } + auto stats_table = cudf::table(std::move(columns)); - // Apply bloom filter membership to row groups for each equality predicate column, literal pair - // and get corresponding boolean columns. - auto [bloom_filter_membership_cols, equality_literals] = - apply_bloom_filters(sources, - input_row_group_indices, - output_dtypes, - output_column_schemas, - filter, - total_row_groups, - stream); - - // Check if we have any bloom filter membership columns - auto const has_bloom_filters = not bloom_filter_membership_cols.empty(); - - // Append bloom filter membership columns to stats columns to get combined columns if needed - if (has_bloom_filters) { - columns.insert(columns.end(), - std::make_move_iterator(bloom_filter_membership_cols.begin()), - std::make_move_iterator(bloom_filter_membership_cols.end())); - } - - auto combined_table = cudf::table(std::move(columns)); - - // Convert AST to a CombinedAST (StatsAST and BloomfilterAST) expression - combined_expression_converter combined_expr{ - filter.get(), num_input_columns, equality_literals, has_bloom_filters}; - - // Filter combined table with the AST expression and collect filtered row group indices - return collect_filtered_row_group_indices( - combined_table, combined_expr.get_converted_expr(), input_row_group_indices, stream, mr); -} - -// Convert AST expression to a CombinedAST (StatsAST and BloomfilterAST) -combined_expression_converter::combined_expression_converter( - ast::expression const& expr, - size_type num_input_columns, - std::vector> const& equality_literals, - bool has_bloom_filters) - : _has_bloom_filters{has_bloom_filters} -{ - // Set the num columns and copy equality literals - _num_input_columns = num_input_columns; - _equality_literals = equality_literals; - - // Compute and store columns literals offsets - _col_literals_offsets.reserve(_num_input_columns + 1); - _col_literals_offsets.emplace_back(0); - - std::transform(equality_literals.begin(), - equality_literals.end(), - std::back_inserter(_col_literals_offsets), - [&](auto const& col_literal_map) { - return _col_literals_offsets.back() + - static_cast(col_literal_map.size()); - }); - - // Add this visitor - expr.accept(*this); -} - -std::reference_wrapper combined_expression_converter::visit( - ast::literal const& expr) -{ - _converted_expr = std::reference_wrapper(expr); - return expr; -} - -std::reference_wrapper combined_expression_converter::visit( - ast::column_reference const& expr) -{ - CUDF_EXPECTS(expr.get_table_source() == ast::table_reference::LEFT, - "CombinedAST (Stats and Bloomfilter) supports only left table"); - CUDF_EXPECTS(expr.get_column_index() < _num_input_columns, - "Column index cannot be more than number of columns in the table"); - _converted_expr = std::reference_wrapper(expr); - return expr; -} - -std::reference_wrapper combined_expression_converter::visit( - ast::operation const& expr) -{ - using cudf::ast::ast_operator; - auto const operands = expr.get_operands(); - auto const op = expr.get_operator(); - - if (auto* v = dynamic_cast(&operands[0].get())) { - // First operand should be column reference, second should be literal. - CUDF_EXPECTS(cudf::ast::detail::ast_operator_arity(op) == 2, - "Only binary operations are supported on column reference"); - CUDF_EXPECTS(dynamic_cast(&operands[1].get()) != nullptr, - "Second operand of binary operation with column reference must be a literal"); - v->accept(*this); - auto const col_index = v->get_column_index(); - switch (op) { - /* transform to stats conditions. op(col, literal) - col1 == val --> vmin <= val && vmax >= val && bloom_filter[col1, val].contains(val) - col1 != val --> !(vmin == val && vmax == val) - col1 > val --> vmax > val - col1 < val --> vmin < val - col1 >= val --> vmax >= val - col1 <= val --> vmin <= val - */ - case ast_operator::EQUAL: { - auto const& vmin = _col_ref.emplace_back(col_index * 2); - auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); - auto const& op1 = - _operators.emplace_back(ast_operator::LESS_EQUAL, vmin, operands[1].get()); - auto const& op2 = - _operators.emplace_back(ast_operator::GREATER_EQUAL, vmax, operands[1].get()); - auto const& stats_op = _operators.emplace_back(ast::ast_operator::LOGICAL_AND, op1, op2); - - // Use this input column's bloom filter membership column as well if available. - if (_has_bloom_filters) { - auto const& equality_literals = _equality_literals[col_index]; - auto col_literal_offset = (_num_input_columns * 2) + _col_literals_offsets[col_index]; - auto const literal_iter = - std::find(equality_literals.cbegin(), - equality_literals.cend(), - dynamic_cast(&operands[1].get())); - CUDF_EXPECTS(literal_iter != equality_literals.end(), "Could not find the literal ptr"); - col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); - - // Evaluate boolean is_true(value) expression as NOT(NOT(value)) - auto const& value = _col_ref.emplace_back(col_literal_offset); - auto const& op = _operators.emplace_back(ast_operator::NOT, value); - auto const& bloom_filter_op = _operators.emplace_back(ast_operator::NOT, op); - - // Logical and between stats and bloom filter operators - _operators.emplace_back(ast_operator::LOGICAL_AND, stats_op, bloom_filter_op); - } - break; - } - case ast_operator::NOT_EQUAL: { - auto const& vmin = _col_ref.emplace_back(col_index * 2); - auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); - auto const& op1 = _operators.emplace_back(ast_operator::NOT_EQUAL, vmin, vmax); - auto const& op2 = _operators.emplace_back(ast_operator::NOT_EQUAL, vmax, operands[1].get()); - _operators.emplace_back(ast_operator::LOGICAL_OR, op1, op2); - break; - } - case ast_operator::LESS: [[fallthrough]]; - case ast_operator::LESS_EQUAL: { - auto const& vmin = _col_ref.emplace_back(col_index * 2); - _operators.emplace_back(op, vmin, operands[1].get()); - break; - } - case ast_operator::GREATER: [[fallthrough]]; - case ast_operator::GREATER_EQUAL: { - auto const& vmax = _col_ref.emplace_back(col_index * 2 + 1); - _operators.emplace_back(op, vmax, operands[1].get()); - break; - } - default: CUDF_FAIL("Unsupported operation in CombinedAST"); - }; - } else { - auto new_operands = visit_operands(operands); - if (cudf::ast::detail::ast_operator_arity(op) == 2) { - _operators.emplace_back(op, new_operands.front(), new_operands.back()); - } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { - _operators.emplace_back(op, new_operands.front()); - } - } - _converted_expr = std::reference_wrapper(_operators.back()); - return std::reference_wrapper(_operators.back()); -} + // Converts AST to StatsAST with reference to min, max columns in above `stats_table`. + stats_expression_converter const stats_expr{filter.get(), + static_cast(output_dtypes.size())}; + auto stats_ast = stats_expr.get_stats_expr(); + auto predicate_col = cudf::detail::compute_column(stats_table, stats_ast.get(), stream, mr); + auto predicate = predicate_col->view(); + CUDF_EXPECTS(predicate.type().id() == cudf::type_id::BOOL8, + "Filter expression must return a boolean column"); -std::vector> -combined_expression_converter::visit_operands( - cudf::host_span const> operands) -{ - std::vector> transformed_operands; - for (auto const& operand : operands) { - auto const new_operand = operand.get().accept(*this); - transformed_operands.push_back(new_operand); - } - return transformed_operands; + // Filter stats table with StatsAST expression and collect filtered row group indices + auto const filtered_row_group_indices = collect_filtered_row_group_indices( + stats_table, stats_expr.get_stats_expr(), input_row_group_indices, stream, mr); + + // Span of row groups to apply bloom filtering on. + auto const bloom_filter_input_row_groups = + filtered_row_group_indices.has_value() + ? host_span const>(filtered_row_group_indices.value()) + : input_row_group_indices; + + // Apply bloom filtering on the bloom filter input row groups + auto const bloom_filtered_row_groups = apply_bloom_filters(sources, + bloom_filter_input_row_groups, + output_dtypes, + output_column_schemas, + filter, + total_row_groups, + stream); + + // Return bloom filtered row group indices iff collected + return bloom_filtered_row_groups.has_value() ? bloom_filtered_row_groups + : filtered_row_group_indices; } // convert column named expression to column index reference expression diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 859d9aadc94..e717ee5c126 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -372,28 +372,26 @@ class aggregate_reader_metadata { rmm::cuda_stream_view stream) const; /** - * @brief Computes bloom filter membership for the row groups based on predicate filter + * @brief Filters the row groups using bloom filters * * @param sources Dataset sources * @param row_group_indices Lists of input row groups to read, one per source * @param output_dtypes Datatypes of of output columns * @param output_column_schemas schema indices of output columns * @param filter AST expression to filter row groups based on bloom filter membership - * @param total_row_groups Total number of row groups across all input sources. + * @param total_row_groups Total number of row groups across all data sources * @param stream CUDA stream used for device memory operations and kernel launches * - * @return A pair of list of bloom filter membership columns and a list of pointers to (equality - * predicate) literals associated with each input column. + * @return Filtered row group indices, if any is filtered */ - [[nodiscard]] std::pair>, - std::vector>> - apply_bloom_filters(host_span const> sources, - host_span const> input_row_group_indices, - host_span output_dtypes, - host_span output_column_schemas, - std::reference_wrapper filter, - size_t total_row_groups, - rmm::cuda_stream_view stream) const; + [[nodiscard]] std::optional>> apply_bloom_filters( + host_span const> sources, + host_span const> input_row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + std::reference_wrapper filter, + size_t total_row_groups, + rmm::cuda_stream_view stream) const; /** * @brief Filters and reduces down to a selection of row groups @@ -445,71 +443,6 @@ class aggregate_reader_metadata { type_id timestamp_type_id); }; -/** - * @brief Converts AST expression to a CombinedAST (StatsAST and BloomfilterAST) for comparing with - * column statistics and testing bloom filter membership altogether. This is used in row group - * filtering based on predicate. - * - * statistics min value of a column is referenced by column_index*2 - * statistics max value of a column is referenced by column_index*2+1 - * bloom filter membership of each (column, literal) pair is appended to the end of stats. - * - */ -class combined_expression_converter : public ast::detail::expression_transformer { - public: - combined_expression_converter() = default; - combined_expression_converter(ast::expression const& expr, - size_type num_input_columns, - std::vector> const& equality_literals, - bool has_bloom_filters); - /** - * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) - */ - std::reference_wrapper visit(ast::literal const& expr) override; - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) - */ - std::reference_wrapper visit(ast::column_reference const& expr) override; - - /** - * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) - */ - std::reference_wrapper visit( - ast::column_name_reference const& expr) override - { - CUDF_FAIL("Column name reference is not supported in CombinedAST"); - } - /** - * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) - */ - std::reference_wrapper visit(ast::operation const& expr) override; - /** - * @brief Returns the AST to apply Column chunk statistics and bloom filter membership - * - * @return AST operation expression - */ - [[nodiscard]] std::reference_wrapper get_converted_expr() const - { - return _converted_expr.value().get(); - } - - protected: - std::vector> visit_operands( - cudf::host_span const> operands); - - std::optional> _converted_expr; - size_type _num_columns; - std::list _col_ref; - std::list _operators; - std::vector> _equality_literals; - size_type _num_input_columns; - - private: - bool _has_bloom_filters; - std::vector _col_literals_offsets; -}; - /** * @brief Converts named columns to index reference columns * From 442de80211a17dde95e1d970ca4afd5a0c7f0047 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 20:09:52 +0000 Subject: [PATCH 56/82] Revert an extra eol --- cpp/src/io/parquet/predicate_pushdown.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 93edbb161ed..7ae671fbf96 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -389,7 +389,6 @@ class stats_expression_converter : public ast::detail::expression_transformer { std::list _col_ref; std::list _operators; }; - } // namespace std::optional>> aggregate_reader_metadata::filter_row_groups( From f7952d45bd339c743bfd813441cb5f01e0e057da Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 20:10:51 +0000 Subject: [PATCH 57/82] Revert extra eol --- cpp/src/io/parquet/reader_impl_helpers.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/cpp/src/io/parquet/reader_impl_helpers.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index b9ed0a8c65b..27280de25eb 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1049,7 +1049,6 @@ aggregate_reader_metadata::select_row_groups( host_span const>(filtered_row_group_indices.value()); } } - std::vector selection; auto [rows_to_skip, rows_to_read] = [&]() { if (not row_group_indices.empty()) { return std::pair{}; } From 4d0c57031f6bc441063bf2e3b42601d274ab1144 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 22:02:15 +0000 Subject: [PATCH 58/82] Read bloom filter data sync --- cpp/src/io/parquet/bloom_filter_reader.cu | 44 +++++++++++------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index d01ff0c1c63..0485321bc44 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -366,7 +366,7 @@ class bloom_filter_expression_converter : public equality_literals_collector { }; /** - * @brief Asynchronously reads bloom filters data to device. + * @brief Reads bloom filters data to device. * * @param sources Dataset sources * @param num_chunks Number of total column chunks to read @@ -375,17 +375,14 @@ class bloom_filter_expression_converter : public equality_literals_collector { * @param bloom_filter_sizes Bloom filter sizes for all chunks * @param chunk_source_map Association between each column chunk and its source * @param stream CUDA stream used for device memory operations and kernel launches - * - * @return A future object for reading synchronization */ -std::future read_bloom_filter_data_async( - host_span const> sources, - size_t num_chunks, - cudf::host_span bloom_filter_data, - cudf::host_span> bloom_filter_offsets, - cudf::host_span> bloom_filter_sizes, - std::vector const& chunk_source_map, - rmm::cuda_stream_view stream) +void read_bloom_filter_data(host_span const> sources, + size_t num_chunks, + cudf::host_span bloom_filter_data, + cudf::host_span> bloom_filter_offsets, + cudf::host_span> bloom_filter_sizes, + std::vector const& chunk_source_map, + rmm::cuda_stream_view stream) { // Read tasks for bloom filter data std::vector> read_tasks; @@ -421,9 +418,9 @@ std::future read_bloom_filter_data_async( // Get the hardcoded words_per_block value from `cuco::arrow_filter_policy` using a temporary // `std::byte` key type. - auto constexpr words_per_block = - cuco::arrow_filter_policy>::words_per_block; + auto constexpr words_per_block = cuco::arrow_filter_policy< + cuda::std::byte, + cudf::hashing::detail::XXHash_64>::words_per_block; // Check if the bloom filter header is valid. auto const is_header_valid = @@ -475,7 +472,7 @@ std::future read_bloom_filter_data_async( } }; - return std::async(std::launch::async, sync_fn, std::move(read_tasks)); + std::async(std::launch::async, sync_fn, std::move(read_tasks)).wait(); } } // namespace @@ -532,15 +529,14 @@ std::vector aggregate_reader_metadata::read_bloom_filters( // Vector to hold bloom filter data std::vector bloom_filter_data(num_chunks); - // Wait on bloom filter data read tasks - read_bloom_filter_data_async(sources, - num_chunks, - bloom_filter_data, - bloom_filter_offsets, - bloom_filter_sizes, - chunk_source_map, - stream) - .wait(); + // Read bloom filter data + read_bloom_filter_data(sources, + num_chunks, + bloom_filter_data, + bloom_filter_offsets, + bloom_filter_sizes, + chunk_source_map, + stream); // Return bloom filter data return bloom_filter_data; From 67c6247fbce934ea5c94cc0a9bdfd966090bbaa2 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:11:04 -0800 Subject: [PATCH 59/82] Update cpp/src/io/parquet/bloom_filter_reader.cu Co-authored-by: Yunsong Wang --- cpp/src/io/parquet/bloom_filter_reader.cu | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 0485321bc44..f982b143949 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -82,8 +82,8 @@ struct bloom_filter_caster { // Query literal in bloom filters from each column chunk (row group). thrust::for_each( rmm::exec_policy_nosync(stream), - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(total_row_groups), + thrust::counting_iterator{0}, + thrust::counting_iterator{total_row_groups}, [filter_span = bloom_filter_spans.data(), d_scalar = literal->get_value(), col_idx = equality_col_idx, From 40c80b7f89ccd02655ed71e6c7a143490ebaaa37 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 22:17:55 +0000 Subject: [PATCH 60/82] strong type for int96 timestamp --- cpp/src/io/parquet/bloom_filter_reader.cu | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 0485321bc44..0f44c93dc93 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -55,7 +55,9 @@ struct bloom_filter_caster { size_t total_row_groups; size_t num_equality_columns; - template + enum class is_int96_timestamp : bool { YES, NO }; + + template std::unique_ptr query_bloom_filter(cudf::size_type equality_col_idx, cudf::data_type dtype, ast::literal const* const literal, @@ -116,7 +118,8 @@ struct bloom_filter_caster { // If int96_timestamp type, convert literal to string_view and query bloom // filter - if constexpr (cuda::std::is_same_v and timestamp_is_int96) { + if constexpr (cuda::std::is_same_v and + IS_INT96_TIMESTAMP == is_int96_timestamp::YES) { auto const int128_key = static_cast<__int128_t>(d_scalar.value()); cudf::string_view probe_key{reinterpret_cast(&int128_key), 12}; results[row_group_idx] = filter.contains(probe_key); @@ -146,8 +149,8 @@ struct bloom_filter_caster { CUDF_FAIL("Compound types don't support equality predicate"); } else if constexpr (cudf::is_timestamp()) { if (parquet_types[equality_col_idx] == Type::INT96) { - // For INT96 timestamps, use cudf::string_view type and set timestamp_is_int96 - return query_bloom_filter( + // For INT96 timestamps, use cudf::string_view type and set is_int96_timestamp to YES + return query_bloom_filter( equality_col_idx, dtype, literal, stream, mr); } else { return query_bloom_filter(equality_col_idx, dtype, literal, stream, mr); From c5f8150408c2258be8d7ea8d079d982d909a89a0 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Wed, 4 Dec 2024 22:22:22 +0000 Subject: [PATCH 61/82] Remove unused header --- cpp/src/io/parquet/bloom_filter_reader.cu | 1 - 1 file changed, 1 deletion(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index a58dd557b75..f03e919e806 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -38,7 +38,6 @@ #include #include -#include #include #include From 44652775979d47a6656e4a41aad47a31245c5d68 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:11:49 -0800 Subject: [PATCH 62/82] Apply suggestions from code review Co-authored-by: Vukasin Milovanovic --- cpp/src/io/parquet/bloom_filter_reader.cu | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index f03e919e806..605389ff75f 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -440,7 +440,7 @@ void read_bloom_filter_data(host_span const> sources // Bloom filter header size auto const bloom_filter_header_size = static_cast(cp.bytecount()); - size_t const bitset_size = static_cast(header.num_bytes); + auto const bitset_size = static_cast(header.num_bytes); // Check if we already read in the filter bitset in the initial read. if (initial_read_size >= bloom_filter_header_size + bitset_size) { @@ -593,14 +593,12 @@ std::optional>> aggregate_reader_metadata::ap // Collect schema indices of columns with equality predicate(s) std::vector equality_col_schemas; - std::for_each(thrust::make_counting_iterator(0), - thrust::make_counting_iterator(output_column_schemas.size()), - [&](auto col_idx) { - // Only for columns that have a non-empty list of literals associated with it - if (equality_literals[col_idx].size()) { - equality_col_schemas.emplace_back(output_column_schemas[col_idx]); - } - }); + thrust::copy_if(thrust::host, + output_column_schemas.begin(), + output_column_schemas.end(), + equality_literals.begin(), + std::back_inserter(equality_col_schemas), + [](auto& eq_literals) { not return eq_literals.empty(); }); // Read bloom filters and add literal membership columns. std::vector> bloom_filter_membership_columns; @@ -608,7 +606,7 @@ std::optional>> aggregate_reader_metadata::ap // Return early if no column with equality predicate(s) if (equality_col_schemas.empty()) { return std::nullopt; } - // Read a vector of bloom filter bitset device buffers for all column with equality + // Read a vector of bloom filter bitset device buffers for all columns with equality // predicate(s) across all row groups auto bloom_filter_data = read_bloom_filters( sources, input_row_group_indices, equality_col_schemas, total_row_groups, stream); From 38887328f6f03ec8c800544dd1b670152acf4093 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Mon, 9 Dec 2024 20:28:10 +0000 Subject: [PATCH 63/82] Apply suggestions --- cpp/src/io/parquet/bloom_filter_reader.cu | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 605389ff75f..6aa688aefb2 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -440,7 +440,7 @@ void read_bloom_filter_data(host_span const> sources // Bloom filter header size auto const bloom_filter_header_size = static_cast(cp.bytecount()); - auto const bitset_size = static_cast(header.num_bytes); + auto const bitset_size = static_cast(header.num_bytes); // Check if we already read in the filter bitset in the initial read. if (initial_read_size >= bloom_filter_header_size + bitset_size) { @@ -594,15 +594,12 @@ std::optional>> aggregate_reader_metadata::ap // Collect schema indices of columns with equality predicate(s) std::vector equality_col_schemas; thrust::copy_if(thrust::host, - output_column_schemas.begin(), + output_column_schemas.begin(), output_column_schemas.end(), equality_literals.begin(), std::back_inserter(equality_col_schemas), [](auto& eq_literals) { not return eq_literals.empty(); }); - // Read bloom filters and add literal membership columns. - std::vector> bloom_filter_membership_columns; - // Return early if no column with equality predicate(s) if (equality_col_schemas.empty()) { return std::nullopt; } @@ -632,13 +629,14 @@ std::optional>> aggregate_reader_metadata::ap auto const bloom_filter_spans = cudf::detail::make_device_uvector_async(h_bloom_filter_spans, stream, mr); - // Create a bloom filter query table caster. + // Create a bloom filter query table caster bloom_filter_caster const bloom_filter_col{ bloom_filter_spans, parquet_types, total_row_groups, equality_col_schemas.size()}; // Converts bloom filter membership for equality predicate columns to a table // containing a column for each `col[i] == literal` predicate to be evaluated. // The table contains #sources * #column_chunks_per_src rows. + std::vector> bloom_filter_membership_columns; size_t equality_col_idx = 0; std::for_each( thrust::make_counting_iterator(0), @@ -659,6 +657,8 @@ std::optional>> aggregate_reader_metadata::ap } equality_col_idx++; }); + + // Create a table from columns auto bloom_filter_membership_table = cudf::table(std::move(bloom_filter_membership_columns)); // Convert AST to BloomfilterAST expression with reference to bloom filter membership From 8bc8927af3241a0da36a02b9b67585ec8db73460 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:32:24 -0800 Subject: [PATCH 64/82] Update cpp/src/io/parquet/reader_impl_helpers.hpp Co-authored-by: Vukasin Milovanovic --- cpp/src/io/parquet/reader_impl_helpers.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index e717ee5c126..d61ae241504 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -357,7 +357,7 @@ class aggregate_reader_metadata { * * @param sources Lists of input datasources * @param row_group_indices Lists of row groups to read, one per source - * @param output_dtypes Datatypes of of output columns + * @param output_dtypes Datatypes of output columns * @param output_column_schemas schema indices of output columns * @param filter AST expression to filter row groups based on Column chunk statistics * @param stream CUDA stream used for device memory operations and kernel launches From d719e65757aaf117945111a041e59df3b7698030 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:32:33 -0800 Subject: [PATCH 65/82] Update cpp/src/io/parquet/reader_impl_helpers.hpp Co-authored-by: Vukasin Milovanovic --- cpp/src/io/parquet/reader_impl_helpers.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index d61ae241504..b670c1a63d9 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -376,7 +376,7 @@ class aggregate_reader_metadata { * * @param sources Dataset sources * @param row_group_indices Lists of input row groups to read, one per source - * @param output_dtypes Datatypes of of output columns + * @param output_dtypes Datatypes of output columns * @param output_column_schemas schema indices of output columns * @param filter AST expression to filter row groups based on bloom filter membership * @param total_row_groups Total number of row groups across all data sources From 03cf07f08b96d414d6555723163f96b5f0f437e0 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Mon, 9 Dec 2024 21:02:43 +0000 Subject: [PATCH 66/82] Move equality_literals instead of copying --- cpp/src/io/parquet/bloom_filter_reader.cu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 6aa688aefb2..80e9b5b315e 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -246,7 +246,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { * * @return Vectors of equality literals, one per input table column */ - [[nodiscard]] std::vector> const get_equality_literals() const + [[nodiscard]] std::vector> get_equality_literals() const { return _equality_literals; } From c92d3265b6e530af3a581d205efc25de44c4c432 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Mon, 9 Dec 2024 21:19:06 +0000 Subject: [PATCH 67/82] Minor --- cpp/src/io/parquet/bloom_filter_reader.cu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 80e9b5b315e..48f82c6e3d2 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -598,7 +598,7 @@ std::optional>> aggregate_reader_metadata::ap output_column_schemas.end(), equality_literals.begin(), std::back_inserter(equality_col_schemas), - [](auto& eq_literals) { not return eq_literals.empty(); }); + [](auto& eq_literals) { return not eq_literals.empty(); }); // Return early if no column with equality predicate(s) if (equality_col_schemas.empty()) { return std::nullopt; } From 82083f994e8d34eed15a875c4e3c8d33dfcef2ce Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 10 Dec 2024 01:52:35 +0000 Subject: [PATCH 68/82] Use spans instead of passing around vectors --- cpp/src/io/parquet/bloom_filter_reader.cu | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 48f82c6e3d2..0b16332fa0a 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -246,11 +246,14 @@ class equality_literals_collector : public ast::detail::expression_transformer { * * @return Vectors of equality literals, one per input table column */ - [[nodiscard]] std::vector> get_equality_literals() const + [[nodiscard]] cudf::host_span const> get_equality_literals() const { - return _equality_literals; + return {_equality_literals}; } + private: + std::vector> _equality_literals; + protected: std::vector> visit_operands( cudf::host_span const> operands) @@ -263,7 +266,6 @@ class equality_literals_collector : public ast::detail::expression_transformer { return transformed_operands; } std::optional> _bloom_filter_expr; - std::vector> _equality_literals; std::list _col_ref; std::list _operators; size_type _num_input_columns; @@ -278,11 +280,11 @@ class bloom_filter_expression_converter : public equality_literals_collector { bloom_filter_expression_converter( ast::expression const& expr, size_type num_input_columns, - std::vector> const& equality_literals) + cudf::host_span const> equality_literals) + : _equality_literals{equality_literals} { - // Set the num columns and copy equality literals + // Set the num columns _num_input_columns = num_input_columns; - _equality_literals = equality_literals; // Compute and store columns literals offsets _col_literals_offsets.reserve(_num_input_columns + 1); @@ -303,7 +305,8 @@ class bloom_filter_expression_converter : public equality_literals_collector { /** * @brief Delete equality literals getter as no longer needed */ - [[nodiscard]] std::vector> get_equality_literals() = delete; + [[nodiscard]] cudf::host_span const> get_equality_literals() const = + delete; // Bring all overloads of `visit` from equality_predicate_collector into scope using equality_literals_collector::visit; @@ -365,6 +368,7 @@ class bloom_filter_expression_converter : public equality_literals_collector { private: std::vector _col_literals_offsets; + cudf::host_span const> _equality_literals; }; /** @@ -588,8 +592,8 @@ std::optional>> aggregate_reader_metadata::ap auto mr = cudf::get_current_device_resource_ref(); // Collect equality literals for each input table column - auto const equality_literals = - equality_literals_collector{filter.get(), num_input_columns}.get_equality_literals(); + auto const eq_literals_collector = equality_literals_collector{filter.get(), num_input_columns}; + auto const equality_literals = eq_literals_collector.get_equality_literals(); // Collect schema indices of columns with equality predicate(s) std::vector equality_col_schemas; From 6918a4070528e1aa2330263e1155cf72808b1a19 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 10 Dec 2024 02:08:45 +0000 Subject: [PATCH 69/82] Minor --- cpp/src/io/parquet/bloom_filter_reader.cu | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 0b16332fa0a..f9f236a6cec 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -246,9 +246,9 @@ class equality_literals_collector : public ast::detail::expression_transformer { * * @return Vectors of equality literals, one per input table column */ - [[nodiscard]] cudf::host_span const> get_equality_literals() const + [[nodiscard]] std::vector> get_equality_literals() { - return {_equality_literals}; + return std::move(_equality_literals); } private: @@ -305,8 +305,7 @@ class bloom_filter_expression_converter : public equality_literals_collector { /** * @brief Delete equality literals getter as no longer needed */ - [[nodiscard]] cudf::host_span const> get_equality_literals() const = - delete; + [[nodiscard]] std::vector> get_equality_literals() = delete; // Bring all overloads of `visit` from equality_predicate_collector into scope using equality_literals_collector::visit; @@ -592,8 +591,8 @@ std::optional>> aggregate_reader_metadata::ap auto mr = cudf::get_current_device_resource_ref(); // Collect equality literals for each input table column - auto const eq_literals_collector = equality_literals_collector{filter.get(), num_input_columns}; - auto const equality_literals = eq_literals_collector.get_equality_literals(); + auto const equality_literals = + equality_literals_collector{filter.get(), num_input_columns}.get_equality_literals(); // Collect schema indices of columns with equality predicate(s) std::vector equality_col_schemas; @@ -668,7 +667,7 @@ std::optional>> aggregate_reader_metadata::ap // Convert AST to BloomfilterAST expression with reference to bloom filter membership // in above `bloom_filter_membership_table` bloom_filter_expression_converter bloom_filter_expr{ - filter.get(), num_input_columns, equality_literals}; + filter.get(), num_input_columns, {equality_literals}}; // Filter bloom filter membership table with the BloomfilterAST expression and collect // filtered row group indices From 85cdc0099f8f29b2644382c13077a25c98fdfb12 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 10 Dec 2024 02:22:02 +0000 Subject: [PATCH 70/82] Make `get_equality_literals()` safe again --- cpp/src/io/parquet/bloom_filter_reader.cu | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index f9f236a6cec..0b720419fad 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -246,7 +246,7 @@ class equality_literals_collector : public ast::detail::expression_transformer { * * @return Vectors of equality literals, one per input table column */ - [[nodiscard]] std::vector> get_equality_literals() + [[nodiscard]] std::vector> get_equality_literals() && { return std::move(_equality_literals); } @@ -305,7 +305,7 @@ class bloom_filter_expression_converter : public equality_literals_collector { /** * @brief Delete equality literals getter as no longer needed */ - [[nodiscard]] std::vector> get_equality_literals() = delete; + [[nodiscard]] std::vector> get_equality_literals() && = delete; // Bring all overloads of `visit` from equality_predicate_collector into scope using equality_literals_collector::visit; From fdf8fc87a2a30a75e252aadc032cabab5920e081 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 10 Dec 2024 02:46:15 +0000 Subject: [PATCH 71/82] Update counting_iterator --- cpp/src/io/parquet/bloom_filter_reader.cu | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 0b720419fad..ba47156a993 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -394,8 +394,8 @@ void read_bloom_filter_data(host_span const> sources // Read bloom filters for all column chunks std::for_each( - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(num_chunks), + thrust::counting_iterator(0), + thrust::counting_iterator(num_chunks), [&](auto const chunk) { // If bloom filter offset absent, fill in an empty buffer and skip ahead if (not bloom_filter_offsets[chunk].has_value()) { @@ -504,8 +504,8 @@ std::vector aggregate_reader_metadata::read_bloom_filters( size_type chunk_count = 0; // For all data sources - std::for_each(thrust::make_counting_iterator(0), - thrust::make_counting_iterator(row_group_indices.size()), + std::for_each(thrust::counting_iterator(0), + thrust::counting_iterator(row_group_indices.size()), [&](auto const src_index) { // Get all row group indices in the data source auto const& rg_indices = row_group_indices[src_index]; @@ -642,8 +642,8 @@ std::optional>> aggregate_reader_metadata::ap std::vector> bloom_filter_membership_columns; size_t equality_col_idx = 0; std::for_each( - thrust::make_counting_iterator(0), - thrust::make_counting_iterator(output_dtypes.size()), + thrust::counting_iterator(0), + thrust::counting_iterator(output_dtypes.size()), [&](auto input_col_idx) { auto const& dtype = output_dtypes[input_col_idx]; From 10a8f5a0b2d45761401551125f89f76bbba6bb58 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 10 Dec 2024 22:07:57 +0000 Subject: [PATCH 72/82] Minor changes --- cpp/include/cudf/hashing/detail/xxhash_64.cuh | 3 +-- cpp/src/io/parquet/arrow_filter_policy.cuh | 16 +++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/cpp/include/cudf/hashing/detail/xxhash_64.cuh b/cpp/include/cudf/hashing/detail/xxhash_64.cuh index d57cd84efa8..d77d040b365 100644 --- a/cpp/include/cudf/hashing/detail/xxhash_64.cuh +++ b/cpp/include/cudf/hashing/detail/xxhash_64.cuh @@ -29,8 +29,7 @@ namespace cudf::hashing::detail { template struct XXHash_64 { - using argument_type = Key; - using result_type = std::uint64_t; + using result_type = std::uint64_t; CUDF_HOST_DEVICE constexpr XXHash_64(uint64_t seed = cudf::DEFAULT_HASH_SEED) : _impl{seed} {} diff --git a/cpp/src/io/parquet/arrow_filter_policy.cuh b/cpp/src/io/parquet/arrow_filter_policy.cuh index dfb6745bed8..74923b46a4a 100644 --- a/cpp/src/io/parquet/arrow_filter_policy.cuh +++ b/cpp/src/io/parquet/arrow_filter_policy.cuh @@ -30,8 +30,6 @@ namespace cuco { * @brief A policy that defines how Arrow Block-Split Bloom Filter generates and stores a key's * fingerprint. * - * @note: This file is a part of cuCollections. Copied here until we get a cuco bump for cudf. - * * Reference: * https://github.com/apache/arrow/blob/be1dcdb96b030639c0b56955c4c62f9d6b03f473/cpp/src/parquet/bloom_filter.cc#L219-L230 * @@ -41,14 +39,14 @@ namespace cuco { * void bulk_insert_and_eval_arrow_policy_bloom_filter(device_vector const& positive_keys, * device_vector const& negative_keys) * { - * using policy_type = cuco::arrow_filter_policy; + * using policy_type = cuco::arrow_filter_policy; * * // Warn or throw if the number of filter blocks is greater than maximum used by Arrow policy. * static_assert(NUM_FILTER_BLOCKS <= policy_type::max_filter_blocks, "NUM_FILTER_BLOCKS must be * in range: [1, 4194304]"); * * // Create a bloom filter with Arrow policy - * cuco::bloom_filter, + * cuco::bloom_filter, * cuda::thread_scope_device, policy_type> filter{NUM_FILTER_BLOCKS}; * * // Add positive keys to the bloom filter @@ -79,15 +77,15 @@ namespace cuco { * @endcode * * @tparam Key The type of the values to generate a fingerprint for. + * @tparam XXHash64 64-bit XXHash hasher implementation for fingerprint generation. */ -template +template class XXHash64> class arrow_filter_policy { public: - using hasher = Hash; ///< Hash function for Arrow bloom filter policy + using hasher = XXHash64; ///< 64-bit XXHash hasher for Arrow bloom filter policy using word_type = std::uint32_t; ///< uint32_t for Arrow bloom filter policy - using hash_argument_type = typename hasher::argument_type; ///< Hash function input type - using hash_result_type = decltype(std::declval()( - std::declval())); ///< hash function output type + using hash_argument_type = Key; ///< Hash function input type + using hash_result_type = std::uint64_t; ///< hash function output type static constexpr uint32_t bits_set_per_block = 8; ///< hardcoded bits set per Arrow filter block static constexpr uint32_t words_per_block = 8; ///< hardcoded words per Arrow filter block From d46504fef4253f0b09b7d2d46d584b3c9adf9d91 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 10 Dec 2024 22:12:37 +0000 Subject: [PATCH 73/82] Minor --- cpp/src/io/parquet/bloom_filter_reader.cu | 9 ++++----- cpp/tests/io/parquet_bloom_filter_test.cu | 5 ++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index ba47156a993..c4c512f7a6f 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -64,8 +64,7 @@ struct bloom_filter_caster { rmm::device_async_resource_ref mr) const { using key_type = T; - using hasher_type = cudf::hashing::detail::XXHash_64; - using policy_type = cuco::arrow_filter_policy; + using policy_type = cuco::arrow_filter_policy; using word_type = typename policy_type::word_type; // Check if the literal has the same type as the predicate column @@ -423,9 +422,9 @@ void read_bloom_filter_data(host_span const> sources // Get the hardcoded words_per_block value from `cuco::arrow_filter_policy` using a temporary // `std::byte` key type. - auto constexpr words_per_block = cuco::arrow_filter_policy< - cuda::std::byte, - cudf::hashing::detail::XXHash_64>::words_per_block; + auto constexpr words_per_block = + cuco::arrow_filter_policy::words_per_block; // Check if the bloom filter header is valid. auto const is_header_valid = diff --git a/cpp/tests/io/parquet_bloom_filter_test.cu b/cpp/tests/io/parquet_bloom_filter_test.cu index c290de4568a..827fc4b03b4 100644 --- a/cpp/tests/io/parquet_bloom_filter_test.cu +++ b/cpp/tests/io/parquet_bloom_filter_test.cu @@ -36,8 +36,7 @@ class ParquetBloomFilterTest : public cudf::test::BaseFixture {}; TEST_F(ParquetBloomFilterTest, TestStrings) { using key_type = StringType; - using hasher_type = cudf::hashing::detail::XXHash_64; - using policy_type = cuco::arrow_filter_policy; + using policy_type = cuco::arrow_filter_policy; using word_type = policy_type::word_type; std::size_t constexpr num_filter_blocks = 4; @@ -65,7 +64,7 @@ TEST_F(ParquetBloomFilterTest, TestStrings) cudf::detail::cuco_allocator> filter{num_filter_blocks, cuco::thread_scope_device, - {hasher_type{cudf::DEFAULT_HASH_SEED}}, + {{cudf::DEFAULT_HASH_SEED}}, cudf::detail::cuco_allocator{rmm::mr::polymorphic_allocator{}, stream}, stream}; From c94ce86c84a4289ba18b886c1894cee66acd65a1 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Tue, 10 Dec 2024 22:21:51 +0000 Subject: [PATCH 74/82] Sync arrow filter policy with cuco --- cpp/src/io/parquet/arrow_filter_policy.cuh | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/cpp/src/io/parquet/arrow_filter_policy.cuh b/cpp/src/io/parquet/arrow_filter_policy.cuh index 74923b46a4a..01f20e86e6c 100644 --- a/cpp/src/io/parquet/arrow_filter_policy.cuh +++ b/cpp/src/io/parquet/arrow_filter_policy.cuh @@ -82,10 +82,10 @@ namespace cuco { template class XXHash64> class arrow_filter_policy { public: - using hasher = XXHash64; ///< 64-bit XXHash hasher for Arrow bloom filter policy - using word_type = std::uint32_t; ///< uint32_t for Arrow bloom filter policy - using hash_argument_type = Key; ///< Hash function input type - using hash_result_type = std::uint64_t; ///< hash function output type + using hasher = XXHash64; ///< 64-bit XXHash hasher for Arrow bloom filter policy + using word_type = std::uint32_t; ///< uint32_t for Arrow bloom filter policy + using key_type = Key; ///< Hash function input type + using hash_value_type = std::uint64_t; ///< hash function output type static constexpr uint32_t bits_set_per_block = 8; ///< hardcoded bits set per Arrow filter block static constexpr uint32_t words_per_block = 8; ///< hardcoded words per Arrow filter block @@ -132,10 +132,7 @@ class arrow_filter_policy { * * @return The hash value of the key */ - __device__ constexpr hash_result_type hash(hash_argument_type const& key) const - { - return hash_(key); - } + __device__ constexpr hash_value_type hash(key_type const& key) const { return hash_(key); } /** * @brief Determines the filter block a key is added into. @@ -152,7 +149,7 @@ class arrow_filter_policy { * @return The block index for the given key's hash value */ template - __device__ constexpr auto block_index(hash_result_type hash, Extent num_blocks) const + __device__ constexpr auto block_index(hash_value_type hash, Extent num_blocks) const { constexpr auto hash_bits = cuda::std::numeric_limits::digits; // TODO: assert if num_blocks > max_filter_blocks @@ -170,7 +167,7 @@ class arrow_filter_policy { * * @return The bit pattern for the word/segment in the filter block */ - __device__ constexpr word_type word_pattern(hash_result_type hash, std::uint32_t word_index) const + __device__ constexpr word_type word_pattern(hash_value_type hash, std::uint32_t word_index) const { // SALT array to calculate bit indexes for the current word auto constexpr salt = SALT(); From d95a1781019952ec5e5c454a7045e1b77cd037ac Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 12 Dec 2024 10:59:48 +0000 Subject: [PATCH 75/82] Address partial reviewer comments and fix new logger header --- cpp/src/io/parquet/bloom_filter_reader.cu | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index c4c512f7a6f..07a93cde21c 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -24,8 +24,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -74,8 +74,7 @@ struct bloom_filter_caster { "Mismatched predicate column and literal types"); // Filter properties - auto constexpr word_size = sizeof(word_type); - auto constexpr words_per_block = policy_type::words_per_block; + auto constexpr bytes_per_block = sizeof(word_type) * policy_type::words_per_block; rmm::device_buffer results{total_row_groups, stream, mr}; @@ -100,7 +99,7 @@ struct bloom_filter_caster { } // Number of filter blocks - auto const num_filter_blocks = filter_size / (word_size * words_per_block); + auto const num_filter_blocks = filter_size / bytes_per_block; // Create a bloom filter view. cuco::bloom_filter_ref Date: Thu, 12 Dec 2024 22:14:58 +0000 Subject: [PATCH 76/82] Revert to direct dtype check until I find a way to get scalar from literal --- cpp/src/io/parquet/bloom_filter_reader.cu | 38 ++++++++++------------ cpp/src/io/parquet/reader_impl_helpers.hpp | 3 +- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 07a93cde21c..f0d8b09640f 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -36,6 +36,7 @@ #include #include +#include #include #include @@ -68,35 +69,30 @@ struct bloom_filter_caster { using word_type = typename policy_type::word_type; // Check if the literal has the same type as the predicate column - CUDF_EXPECTS( - cudf::have_same_types(cudf::column_view{dtype, 0, {}, {}, 0, 0, {}}, - cudf::column_view{literal->get_data_type(), 0, {}, {}, 0, 0, {}}), - "Mismatched predicate column and literal types"); + CUDF_EXPECTS(dtype == literal->get_data_type(), + "Mismatched predicate column and literal types"); // Filter properties auto constexpr bytes_per_block = sizeof(word_type) * policy_type::words_per_block; rmm::device_buffer results{total_row_groups, stream, mr}; + cudf::device_span results_span{static_cast(results.data()), total_row_groups}; // Query literal in bloom filters from each column chunk (row group). - thrust::for_each( + thrust::tabulate( rmm::exec_policy_nosync(stream), - thrust::counting_iterator{0}, - thrust::counting_iterator{total_row_groups}, + results_span.begin(), + results_span.end(), [filter_span = bloom_filter_spans.data(), d_scalar = literal->get_value(), col_idx = equality_col_idx, - num_equality_columns = num_equality_columns, - results = reinterpret_cast(results.data())] __device__(auto row_group_idx) { + num_equality_columns = num_equality_columns] __device__(auto row_group_idx) { // Filter bitset buffer index auto const filter_idx = col_idx + (num_equality_columns * row_group_idx); auto const filter_size = filter_span[filter_idx].size(); // If no bloom filter, then fill in `true` as membership cannot be determined - if (filter_size == 0) { - results[row_group_idx] = true; - return; - } + if (filter_size == 0) { return true; } // Number of filter blocks auto const num_filter_blocks = filter_size / bytes_per_block; @@ -119,10 +115,10 @@ struct bloom_filter_caster { IS_INT96_TIMESTAMP == is_int96_timestamp::YES) { auto const int128_key = static_cast<__int128_t>(d_scalar.value()); cudf::string_view probe_key{reinterpret_cast(&int128_key), 12}; - results[row_group_idx] = filter.contains(probe_key); + return filter.contains(probe_key); } else { // Query the bloom filter and store results - results[row_group_idx] = filter.contains(d_scalar.value()); + return filter.contains(d_scalar.value()); } }); @@ -144,17 +140,19 @@ struct bloom_filter_caster { // List, Struct, Dictionary types are not supported if constexpr (cudf::is_compound() and not std::is_same_v) { CUDF_FAIL("Compound types don't support equality predicate"); - } else if constexpr (cudf::is_timestamp()) { + } + + // For INT96 timestamps, use cudf::string_view type and set is_int96_timestamp to YES + if constexpr (cudf::is_timestamp()) { if (parquet_types[equality_col_idx] == Type::INT96) { // For INT96 timestamps, use cudf::string_view type and set is_int96_timestamp to YES return query_bloom_filter( equality_col_idx, dtype, literal, stream, mr); - } else { - return query_bloom_filter(equality_col_idx, dtype, literal, stream, mr); } - } else { - return query_bloom_filter(equality_col_idx, dtype, literal, stream, mr); } + + // For all other cases + return query_bloom_filter(equality_col_idx, dtype, literal, stream, mr); } }; diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index b670c1a63d9..8b9370cb499 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -513,7 +513,8 @@ class named_to_reference_converter : public ast::detail::expression_transformer * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to be used in cudf::compute_column * - * @return Collected filtered row group indices, if any. + * @return Collected filtered row group indices, one vector per source, if any. A std::nullopt if + * all row groups are required or if the computed predicate is all nulls */ [[nodiscard]] std::optional>> collect_filtered_row_group_indices( cudf::table_view ast_table, From 9d8c0714198106941c8eea02eaf616e95d25f0b3 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 12 Dec 2024 22:21:21 +0000 Subject: [PATCH 77/82] Create a dummy scalar of type T and compare with dtype --- cpp/src/io/parquet/bloom_filter_reader.cu | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index f0d8b09640f..326665089c5 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -68,9 +68,16 @@ struct bloom_filter_caster { using policy_type = cuco::arrow_filter_policy; using word_type = typename policy_type::word_type; - // Check if the literal has the same type as the predicate column - CUDF_EXPECTS(dtype == literal->get_data_type(), - "Mismatched predicate column and literal types"); + // List, Struct, Dictionary types are not supported + if constexpr (cudf::is_compound() and not std::is_same_v) { + CUDF_FAIL("Compound types don't support equality predicate"); + } else { + // Check if the literal has the same type as the predicate column + auto const scalar = cudf::scalar_type_t(T{}, false, stream, mr); + CUDF_EXPECTS(dtype == literal->get_data_type() and + cudf::have_same_types(cudf::column_view{dtype, 0, {}, {}, 0, 0, {}}, scalar), + "Mismatched predicate column and literal types"); + } // Filter properties auto constexpr bytes_per_block = sizeof(word_type) * policy_type::words_per_block; @@ -137,11 +144,6 @@ struct bloom_filter_caster { rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) const { - // List, Struct, Dictionary types are not supported - if constexpr (cudf::is_compound() and not std::is_same_v) { - CUDF_FAIL("Compound types don't support equality predicate"); - } - // For INT96 timestamps, use cudf::string_view type and set is_int96_timestamp to YES if constexpr (cudf::is_timestamp()) { if (parquet_types[equality_col_idx] == Type::INT96) { From 3b8aea0b457cc65ff292ff8ecef7e2ed3c5efdf4 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Thu, 12 Dec 2024 22:23:05 +0000 Subject: [PATCH 78/82] Use a temporary scalar --- cpp/src/io/parquet/bloom_filter_reader.cu | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 326665089c5..9fe1fc3fce9 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -73,9 +73,9 @@ struct bloom_filter_caster { CUDF_FAIL("Compound types don't support equality predicate"); } else { // Check if the literal has the same type as the predicate column - auto const scalar = cudf::scalar_type_t(T{}, false, stream, mr); CUDF_EXPECTS(dtype == literal->get_data_type() and - cudf::have_same_types(cudf::column_view{dtype, 0, {}, {}, 0, 0, {}}, scalar), + cudf::have_same_types(cudf::column_view{dtype, 0, {}, {}, 0, 0, {}}, + cudf::scalar_type_t(T{}, false, stream, mr)), "Mismatched predicate column and literal types"); } From c385537c847ec03a77f67bc519f9b0592c83423e Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Fri, 13 Dec 2024 00:58:38 +0000 Subject: [PATCH 79/82] Recalculate `total_row_groups` in apply_bloom_filter --- cpp/src/io/parquet/bloom_filter_reader.cu | 12 +++++++++++- cpp/src/io/parquet/predicate_pushdown.cpp | 9 ++------- cpp/src/io/parquet/reader_impl_helpers.hpp | 2 -- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 9fe1fc3fce9..0d919ed8e78 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -580,12 +580,22 @@ std::optional>> aggregate_reader_metadata::ap host_span output_dtypes, host_span output_column_schemas, std::reference_wrapper filter, - size_t total_row_groups, rmm::cuda_stream_view stream) const { // Number of input table columns auto const num_input_columns = static_cast(output_dtypes.size()); + // Total number of row groups after StatsAST filtration + auto const total_row_groups = std::accumulate( + input_row_group_indices.begin(), + input_row_group_indices.end(), + size_t{0}, + [](size_t sum, auto const& per_file_row_groups) { return sum + per_file_row_groups.size(); }); + + // Check if we have less than 2B total row groups. + CUDF_EXPECTS(total_row_groups <= std::numeric_limits::max(), + "Total number of row groups exceed the size_type's limit"); + auto mr = cudf::get_current_device_resource_ref(); // Collect equality literals for each input table column diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 7ae671fbf96..a4eb210c9f0 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -471,13 +471,8 @@ std::optional>> aggregate_reader_metadata::fi : input_row_group_indices; // Apply bloom filtering on the bloom filter input row groups - auto const bloom_filtered_row_groups = apply_bloom_filters(sources, - bloom_filter_input_row_groups, - output_dtypes, - output_column_schemas, - filter, - total_row_groups, - stream); + auto const bloom_filtered_row_groups = apply_bloom_filters( + sources, bloom_filter_input_row_groups, output_dtypes, output_column_schemas, filter, stream); // Return bloom filtered row group indices iff collected return bloom_filtered_row_groups.has_value() ? bloom_filtered_row_groups diff --git a/cpp/src/io/parquet/reader_impl_helpers.hpp b/cpp/src/io/parquet/reader_impl_helpers.hpp index 8b9370cb499..57ff8954a23 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -379,7 +379,6 @@ class aggregate_reader_metadata { * @param output_dtypes Datatypes of output columns * @param output_column_schemas schema indices of output columns * @param filter AST expression to filter row groups based on bloom filter membership - * @param total_row_groups Total number of row groups across all data sources * @param stream CUDA stream used for device memory operations and kernel launches * * @return Filtered row group indices, if any is filtered @@ -390,7 +389,6 @@ class aggregate_reader_metadata { host_span output_dtypes, host_span output_column_schemas, std::reference_wrapper filter, - size_t total_row_groups, rmm::cuda_stream_view stream) const; /** From 3693ad1222f22ad3445af03e398333e40076f9f0 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Fri, 13 Dec 2024 22:46:24 +0000 Subject: [PATCH 80/82] Simplify bloom filter expression with ast::tree and handle non-equality operators in the expression --- cpp/src/io/parquet/bloom_filter_reader.cu | 41 +++++++++++------------ 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 0d919ed8e78..7e10f59853b 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -178,7 +178,6 @@ class equality_literals_collector : public ast::detail::expression_transformer { */ std::reference_wrapper visit(ast::literal const& expr) override { - _bloom_filter_expr = std::reference_wrapper(expr); return expr; } @@ -191,7 +190,6 @@ class equality_literals_collector : public ast::detail::expression_transformer { "BloomfilterAST supports only left table"); CUDF_EXPECTS(expr.get_column_index() < _num_input_columns, "Column index cannot be more than number of columns in the table"); - _bloom_filter_expr = std::reference_wrapper(expr); return expr; } @@ -228,15 +226,11 @@ class equality_literals_collector : public ast::detail::expression_transformer { _equality_literals[col_idx].emplace_back(const_cast(literal_ptr)); } } else { - auto new_operands = visit_operands(operands); - if (cudf::ast::detail::ast_operator_arity(op) == 2) { - _operators.emplace_back(op, new_operands.front(), new_operands.back()); - } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { - _operators.emplace_back(op, new_operands.front()); - } + // Just visit the operands and ignore any output + std::ignore = visit_operands(operands); } - _bloom_filter_expr = std::reference_wrapper(_operators.back()); - return std::reference_wrapper(_operators.back()); + + return expr; } /** @@ -263,9 +257,6 @@ class equality_literals_collector : public ast::detail::expression_transformer { } return transformed_operands; } - std::optional> _bloom_filter_expr; - std::list _col_ref; - std::list _operators; size_type _num_input_columns; }; @@ -337,20 +328,25 @@ class bloom_filter_expression_converter : public equality_literals_collector { col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); // Evaluate boolean is_true(value) expression as NOT(NOT(value)) - auto const& value = _col_ref.emplace_back(col_literal_offset); - auto const& op = _operators.emplace_back(ast_operator::NOT, value); - _operators.emplace_back(ast_operator::NOT, op); + auto const& value = _bloom_filter_expr.push(ast::column_reference{col_literal_offset}); + _bloom_filter_expr.push(ast::operation{ + ast_operator::NOT, _bloom_filter_expr.push(ast::operation{ast_operator::NOT, value})}); + } + // For all other expressions, push an always true expression + else { + _bloom_filter_expr.push( + ast::operation{ast_operator::NOT, + _bloom_filter_expr.push(ast::operation{ast_operator::NOT, _always_true})}); } } else { auto new_operands = visit_operands(operands); if (cudf::ast::detail::ast_operator_arity(op) == 2) { - _operators.emplace_back(op, new_operands.front(), new_operands.back()); + _bloom_filter_expr.push(ast::operation{op, new_operands.front(), new_operands.back()}); } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { - _operators.emplace_back(op, new_operands.front()); + _bloom_filter_expr.push(ast::operation{op, new_operands.front()}); } } - _bloom_filter_expr = std::reference_wrapper(_operators.back()); - return std::reference_wrapper(_operators.back()); + return _bloom_filter_expr.back(); } /** @@ -360,12 +356,15 @@ class bloom_filter_expression_converter : public equality_literals_collector { */ [[nodiscard]] std::reference_wrapper get_bloom_filter_expr() const { - return _bloom_filter_expr.value().get(); + return _bloom_filter_expr.back(); } private: std::vector _col_literals_offsets; cudf::host_span const> _equality_literals; + ast::tree _bloom_filter_expr; + cudf::numeric_scalar _always_true_scalar{true}; + ast::literal const _always_true{_always_true_scalar}; }; /** From c2de9fb0f474f01842bb1bb894ec7f3ba28021c0 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:17:03 -0800 Subject: [PATCH 81/82] Apply suggestions from code review Co-authored-by: Vukasin Milovanovic --- cpp/src/io/parquet/bloom_filter_reader.cu | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 7e10f59853b..bd4f90c9e73 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -292,7 +292,7 @@ class bloom_filter_expression_converter : public equality_literals_collector { } /** - * @brief Delete equality literals getter as no longer needed + * @brief Delete equality literals getter as it's not needed in the derived class */ [[nodiscard]] std::vector> get_equality_literals() && = delete; @@ -468,13 +468,9 @@ void read_bloom_filter_data(host_span const> sources }); // Read task sync function - auto sync_fn = [](decltype(read_tasks) read_tasks) { - for (auto& task : read_tasks) { - task.wait(); - } - }; - - std::async(std::launch::async, sync_fn, std::move(read_tasks)).wait(); + for (auto& task : read_tasks) { + task.wait(); + } } } // namespace From 344851c176798fd74093b0111a8395fc0b6f37af Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb Date: Sat, 14 Dec 2024 00:11:29 +0000 Subject: [PATCH 82/82] Minor optimization: Set `have_bloom_filters` while populating `bloom_filter_offsets` --- cpp/src/io/parquet/bloom_filter_reader.cu | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index bd4f90c9e73..cc399494fc9 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -496,6 +496,9 @@ std::vector aggregate_reader_metadata::read_bloom_filters( // Gather all bloom filter offsets and sizes. size_type chunk_count = 0; + // Flag to check if we have at least one valid bloom filter offset + auto have_bloom_filters = false; + // For all data sources std::for_each(thrust::counting_iterator(0), thrust::counting_iterator(row_group_indices.size()), @@ -513,6 +516,9 @@ std::vector aggregate_reader_metadata::read_bloom_filters( bloom_filter_offsets[chunk_count] = col_meta.bloom_filter_offset; bloom_filter_sizes[chunk_count] = col_meta.bloom_filter_length; + // Set `have_bloom_filters` if `bloom_filter_offset` is valid + if (col_meta.bloom_filter_offset.has_value()) { have_bloom_filters = true; } + // Map each column chunk to its source index chunk_source_map[chunk_count] = src_index; chunk_count++; @@ -521,9 +527,7 @@ std::vector aggregate_reader_metadata::read_bloom_filters( }); // Do we have any bloom filters - if (std::any_of(bloom_filter_offsets.cbegin(), - bloom_filter_offsets.cend(), - [](auto const offset) { return offset.has_value(); })) { + if (have_bloom_filters) { // Vector to hold bloom filter data std::vector bloom_filter_data(num_chunks);