From bbf4f7824c23c0c482f52bafdf1ece1213da2f65 Mon Sep 17 00:00:00 2001 From: Vukasin Milovanovic Date: Mon, 13 Jan 2025 11:44:54 -0800 Subject: [PATCH 1/7] Host compression (#17656) Add compression APIs to make the nvCOMP use transparent. Remove direct dependency on nvCOMP in the ORC and Parquet writers. Add multi-threaded host-side compression; currently off by default, can only be enabled via `LIBCUDF_USE_HOST_COMPRESSION` environment variable. Currently the host compression adds D2H + H2D transfers. Avoiding the H2D transfer requires large changes to the writers. Also moved handling of the AUTO compression type to the options classes, which should own such defaults (translate AUTO to SNAPPY in this case). Authors: - Vukasin Milovanovic (https://github.com/vuule) Approvers: - Yunsong Wang (https://github.com/PointKernel) - Shruti Shivakumar (https://github.com/shrshi) - Vyas Ramasubramani (https://github.com/vyasr) URL: https://github.com/rapidsai/cudf/pull/17656 --- cpp/CMakeLists.txt | 2 +- cpp/include/cudf/io/orc.hpp | 22 ++- cpp/src/io/comp/comp.cpp | 163 ++++++++++++++++++++- cpp/src/io/comp/{statistics.cu => comp.cu} | 4 +- cpp/src/io/comp/comp.hpp | 54 ++++++- cpp/src/io/comp/gpuinflate.hpp | 15 +- cpp/src/io/functions.cpp | 3 +- cpp/src/io/orc/orc_gpu.hpp | 4 +- cpp/src/io/orc/stripe_enc.cu | 49 +------ cpp/src/io/orc/writer_impl.cu | 144 ++++++------------ cpp/src/io/orc/writer_impl.hpp | 4 +- cpp/src/io/parquet/writer_impl.cu | 99 +++++-------- cpp/src/io/parquet/writer_impl.hpp | 4 +- cpp/src/io/parquet/writer_impl_helpers.cpp | 46 +----- cpp/src/io/parquet/writer_impl_helpers.hpp | 38 +---- cpp/tests/io/orc_test.cpp | 3 +- cpp/tests/io/parquet_misc_test.cpp | 3 +- 17 files changed, 338 insertions(+), 319 deletions(-) rename cpp/src/io/comp/{statistics.cu => comp.cu} (96%) diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 9dabe4e8800..252cc7897d8 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -477,13 +477,13 @@ add_library( src/io/avro/reader_impl.cu src/io/comp/brotli_dict.cpp src/io/comp/comp.cpp + src/io/comp/comp.cu src/io/comp/cpu_unbz2.cpp src/io/comp/debrotli.cu src/io/comp/gpuinflate.cu src/io/comp/nvcomp_adapter.cpp src/io/comp/nvcomp_adapter.cu src/io/comp/snap.cu - src/io/comp/statistics.cu src/io/comp/uncomp.cpp src/io/comp/unsnap.cu src/io/csv/csv_gpu.cu diff --git a/cpp/include/cudf/io/orc.hpp b/cpp/include/cudf/io/orc.hpp index 163fa20806d..82f7761da2e 100644 --- a/cpp/include/cudf/io/orc.hpp +++ b/cpp/include/cudf/io/orc.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024, NVIDIA CORPORATION. + * Copyright (c) 2020-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -578,7 +578,7 @@ class orc_writer_options { // Specify the sink to use for writer output sink_info _sink; // Specify the compression format to use - compression_type _compression = compression_type::AUTO; + compression_type _compression = compression_type::SNAPPY; // Specify frequency of statistics collection statistics_freq _stats_freq = ORC_STATISTICS_ROW_GROUP; // Maximum size of each stripe (unless smaller than a single row group) @@ -733,7 +733,11 @@ class orc_writer_options { * * @param comp Compression type */ - void set_compression(compression_type comp) { _compression = comp; } + void set_compression(compression_type comp) + { + _compression = comp; + if (comp == compression_type::AUTO) { _compression = compression_type::SNAPPY; } + } /** * @brief Choose granularity of statistics collection. @@ -865,7 +869,7 @@ class orc_writer_options_builder { */ orc_writer_options_builder& compression(compression_type comp) { - options._compression = comp; + options.set_compression(comp); return *this; } @@ -1026,7 +1030,7 @@ class chunked_orc_writer_options { // Specify the sink to use for writer output sink_info _sink; // Specify the compression format to use - compression_type _compression = compression_type::AUTO; + compression_type _compression = compression_type::SNAPPY; // Specify granularity of statistics collection statistics_freq _stats_freq = ORC_STATISTICS_ROW_GROUP; // Maximum size of each stripe (unless smaller than a single row group) @@ -1157,7 +1161,11 @@ class chunked_orc_writer_options { * * @param comp The compression type to use */ - void set_compression(compression_type comp) { _compression = comp; } + void set_compression(compression_type comp) + { + _compression = comp; + if (comp == compression_type::AUTO) { _compression = compression_type::SNAPPY; } + } /** * @brief Choose granularity of statistics collection @@ -1279,7 +1287,7 @@ class chunked_orc_writer_options_builder { */ chunked_orc_writer_options_builder& compression(compression_type comp) { - options._compression = comp; + options.set_compression(comp); return *this; } diff --git a/cpp/src/io/comp/comp.cpp b/cpp/src/io/comp/comp.cpp index 26535bed43b..3800835eaf1 100644 --- a/cpp/src/io/comp/comp.cpp +++ b/cpp/src/io/comp/comp.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2024, NVIDIA CORPORATION. + * Copyright (c) 2018-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,22 +16,45 @@ #include "comp.hpp" +#include "gpuinflate.hpp" +#include "io/utilities/getenv_or.hpp" #include "io/utilities/hostdevice_vector.hpp" #include "nvcomp_adapter.hpp" #include #include +#include #include #include #include #include +#include #include // GZIP compression namespace cudf::io::detail { namespace { +auto& h_comp_pool() +{ + static std::size_t pool_size = + getenv_or("LIBCUDF_HOST_COMPRESSION_NUM_THREADS", std::thread::hardware_concurrency()); + static BS::thread_pool pool(pool_size); + return pool; +} + +std::optional to_nvcomp_compression(compression_type compression) +{ + switch (compression) { + case compression_type::SNAPPY: return nvcomp::compression_type::SNAPPY; + case compression_type::ZSTD: return nvcomp::compression_type::ZSTD; + case compression_type::LZ4: return nvcomp::compression_type::LZ4; + case compression_type::ZLIB: return nvcomp::compression_type::DEFLATE; + default: return std::nullopt; + } +} + /** * @brief GZIP host compressor (includes header) */ @@ -98,8 +121,132 @@ std::vector compress_snappy(host_span src, return cudf::detail::make_std_vector_sync(d_dst, stream); } +void device_compress(compression_type compression, + device_span const> inputs, + device_span const> outputs, + device_span results, + rmm::cuda_stream_view stream) +{ + if (compression == compression_type::NONE) { return; } + + auto const nvcomp_type = to_nvcomp_compression(compression); + auto nvcomp_disabled = nvcomp_type.has_value() ? nvcomp::is_compression_disabled(*nvcomp_type) + : "invalid compression type"; + if (not nvcomp_disabled) { + return nvcomp::batched_compress(*nvcomp_type, inputs, outputs, results, stream); + } + + switch (compression) { + case compression_type::SNAPPY: return gpu_snap(inputs, outputs, results, stream); + default: CUDF_FAIL("Compression error: " + nvcomp_disabled.value()); + } +} + +void host_compress(compression_type compression, + device_span const> inputs, + device_span const> outputs, + device_span results, + rmm::cuda_stream_view stream) +{ + if (compression == compression_type::NONE) { return; } + + auto const num_chunks = inputs.size(); + auto h_results = cudf::detail::make_host_vector(num_chunks, stream); + auto const h_inputs = cudf::detail::make_host_vector_async(inputs, stream); + auto const h_outputs = cudf::detail::make_host_vector_async(outputs, stream); + stream.synchronize(); + + std::vector> tasks; + auto const num_streams = + std::min({num_chunks, + cudf::detail::global_cuda_stream_pool().get_stream_pool_size(), + h_comp_pool().get_thread_count()}); + auto const streams = cudf::detail::fork_streams(stream, num_streams); + for (size_t i = 0; i < num_chunks; ++i) { + auto const cur_stream = streams[i % streams.size()]; + auto task = [d_in = h_inputs[i], d_out = h_outputs[i], cur_stream, compression]() -> size_t { + auto const h_in = cudf::detail::make_host_vector_sync(d_in, cur_stream); + auto const h_out = compress(compression, h_in, cur_stream); + cudf::detail::cuda_memcpy(d_out.subspan(0, h_out.size()), h_out, cur_stream); + return h_out.size(); + }; + tasks.emplace_back(h_comp_pool().submit_task(std::move(task))); + } + + for (auto i = 0ul; i < num_chunks; ++i) { + h_results[i] = {tasks[i].get(), compression_status::SUCCESS}; + } + cudf::detail::cuda_memcpy_async(results, h_results, stream); +} + +[[nodiscard]] bool host_compression_supported(compression_type compression) +{ + switch (compression) { + case compression_type::GZIP: + case compression_type::NONE: return true; + default: return false; + } +} + +[[nodiscard]] bool device_compression_supported(compression_type compression) +{ + auto const nvcomp_type = to_nvcomp_compression(compression); + switch (compression) { + case compression_type::LZ4: + case compression_type::ZLIB: + case compression_type::ZSTD: return not nvcomp::is_compression_disabled(nvcomp_type.value()); + case compression_type::SNAPPY: + case compression_type::NONE: return true; + default: return false; + } +} + +[[nodiscard]] bool use_host_compression( + compression_type compression, + [[maybe_unused]] device_span const> inputs, + [[maybe_unused]] device_span const> outputs) +{ + CUDF_EXPECTS( + not host_compression_supported(compression) or device_compression_supported(compression), + "Unsupported compression type"); + if (not host_compression_supported(compression)) { return false; } + if (not device_compression_supported(compression)) { return true; } + // If both host and device compression are supported, use the host if the env var is set + return getenv_or("LIBCUDF_USE_HOST_COMPRESSION", 0); +} + } // namespace +std::optional compress_max_allowed_chunk_size(compression_type compression) +{ + if (auto nvcomp_type = to_nvcomp_compression(compression); + nvcomp_type.has_value() and not nvcomp::is_compression_disabled(*nvcomp_type)) { + return nvcomp::compress_max_allowed_chunk_size(*nvcomp_type); + } + return std::nullopt; +} + +[[nodiscard]] size_t compress_required_chunk_alignment(compression_type compression) +{ + auto nvcomp_type = to_nvcomp_compression(compression); + if (compression == compression_type::NONE or not nvcomp_type.has_value() or + nvcomp::is_compression_disabled(*nvcomp_type)) { + return 1ul; + } + + return nvcomp::required_alignment(*nvcomp_type); +} + +[[nodiscard]] size_t max_compressed_size(compression_type compression, uint32_t uncompressed_size) +{ + if (compression == compression_type::NONE) { return uncompressed_size; } + + if (auto nvcomp_type = to_nvcomp_compression(compression); nvcomp_type.has_value()) { + return nvcomp::compress_max_output_chunk_size(*nvcomp_type, uncompressed_size); + } + CUDF_FAIL("Unsupported compression type"); +} + std::vector compress(compression_type compression, host_span src, rmm::cuda_stream_view stream) @@ -112,4 +259,18 @@ std::vector compress(compression_type compression, } } +void compress(compression_type compression, + device_span const> inputs, + device_span const> outputs, + device_span results, + rmm::cuda_stream_view stream) +{ + CUDF_FUNC_RANGE(); + if (use_host_compression(compression, inputs, outputs)) { + return host_compress(compression, inputs, outputs, results, stream); + } else { + return device_compress(compression, inputs, outputs, results, stream); + } +} + } // namespace cudf::io::detail diff --git a/cpp/src/io/comp/statistics.cu b/cpp/src/io/comp/comp.cu similarity index 96% rename from cpp/src/io/comp/statistics.cu rename to cpp/src/io/comp/comp.cu index caee9145d2c..af0f73869a2 100644 --- a/cpp/src/io/comp/statistics.cu +++ b/cpp/src/io/comp/comp.cu @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023-2024, NVIDIA CORPORATION. + * Copyright (c) 2023-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -#include "gpuinflate.hpp" +#include "comp.hpp" #include diff --git a/cpp/src/io/comp/comp.hpp b/cpp/src/io/comp/comp.hpp index e16f26e1f06..90932a11499 100644 --- a/cpp/src/io/comp/comp.hpp +++ b/cpp/src/io/comp/comp.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, NVIDIA CORPORATION. + * Copyright (c) 2024-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,5 +57,57 @@ std::vector compress(compression_type compression, host_span src, rmm::cuda_stream_view stream); +/** + * @brief Maximum size of uncompressed chunks that can be compressed. + * + * @param compression Compression type + * @returns maximum chunk size + */ +[[nodiscard]] std::optional compress_max_allowed_chunk_size(compression_type compression); + +/** + * @brief Gets input and output alignment requirements for the given compression type. + * + * @param compression Compression type + * @returns required alignment + */ +[[nodiscard]] size_t compress_required_chunk_alignment(compression_type compression); + +/** + * @brief Gets the maximum size any chunk could compress to in the batch. + * + * @param compression Compression type + * @param uncompressed_size Size of the largest uncompressed chunk in the batch + */ +[[nodiscard]] size_t max_compressed_size(compression_type compression, uint32_t uncompressed_size); + +/** + * @brief Compresses device memory buffers. + * + * @param compression Type of compression of the input data + * @param inputs Device memory buffers to compress + * @param outputs Device memory buffers to store the compressed output + * @param results Compression results + * @param stream CUDA stream used for device memory operations and kernel launches + */ +void compress(compression_type compression, + device_span const> inputs, + device_span const> outputs, + device_span results, + rmm::cuda_stream_view stream); + +/** + * @brief Aggregate results of compression into a single statistics object. + * + * @param inputs List of uncompressed input buffers + * @param results List of compression results + * @param stream CUDA stream to use + * @return writer_compression_statistics + */ +[[nodiscard]] writer_compression_statistics collect_compression_statistics( + device_span const> inputs, + device_span results, + rmm::cuda_stream_view stream); + } // namespace io::detail } // namespace CUDF_EXPORT cudf diff --git a/cpp/src/io/comp/gpuinflate.hpp b/cpp/src/io/comp/gpuinflate.hpp index 4b09bd5a84c..0a35b230242 100644 --- a/cpp/src/io/comp/gpuinflate.hpp +++ b/cpp/src/io/comp/gpuinflate.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2024, NVIDIA CORPORATION. + * Copyright (c) 2018-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -124,17 +124,4 @@ void gpu_snap(device_span const> inputs, device_span results, rmm::cuda_stream_view stream); -/** - * @brief Aggregate results of compression into a single statistics object. - * - * @param inputs List of uncompressed input buffers - * @param results List of compression results - * @param stream CUDA stream to use - * @return writer_compression_statistics - */ -[[nodiscard]] writer_compression_statistics collect_compression_statistics( - device_span const> inputs, - device_span results, - rmm::cuda_stream_view stream); - } // namespace cudf::io::detail diff --git a/cpp/src/io/functions.cpp b/cpp/src/io/functions.cpp index 88423122e16..d63fa9f5c35 100644 --- a/cpp/src/io/functions.cpp +++ b/cpp/src/io/functions.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2024, NVIDIA CORPORATION. + * Copyright (c) 2019-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -766,6 +766,7 @@ void parquet_writer_options_base::set_stats_level(statistics_freq sf) { _stats_l void parquet_writer_options_base::set_compression(compression_type compression) { _compression = compression; + if (compression == compression_type::AUTO) { _compression = compression_type::SNAPPY; } } void parquet_writer_options_base::enable_int96_timestamps(bool req) diff --git a/cpp/src/io/orc/orc_gpu.hpp b/cpp/src/io/orc/orc_gpu.hpp index f4e75f78dec..8b30cee6681 100644 --- a/cpp/src/io/orc/orc_gpu.hpp +++ b/cpp/src/io/orc/orc_gpu.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2024, NVIDIA CORPORATION. + * Copyright (c) 2019-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -407,7 +407,7 @@ void CompactOrcDataStreams(device_2dspan strm_desc, std::optional CompressOrcDataStreams( device_span compressed_data, uint32_t num_compressed_blocks, - CompressionKind compression, + compression_type compression, uint32_t comp_blk_size, uint32_t max_comp_blk_size, uint32_t comp_block_align, diff --git a/cpp/src/io/orc/stripe_enc.cu b/cpp/src/io/orc/stripe_enc.cu index 79ecca0ca99..4f296bb5bfc 100644 --- a/cpp/src/io/orc/stripe_enc.cu +++ b/cpp/src/io/orc/stripe_enc.cu @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2024, NVIDIA CORPORATION. + * Copyright (c) 2019-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,6 @@ */ #include "io/comp/gpuinflate.hpp" -#include "io/comp/nvcomp_adapter.hpp" #include "io/utilities/block_utils.cuh" #include "io/utilities/time_utils.cuh" #include "orc_gpu.hpp" @@ -45,8 +44,6 @@ namespace io { namespace orc { namespace gpu { -namespace nvcomp = cudf::io::detail::nvcomp; - using cudf::detail::device_2dspan; using cudf::io::detail::compression_result; using cudf::io::detail::compression_status; @@ -1362,7 +1359,7 @@ void CompactOrcDataStreams(device_2dspan strm_desc, std::optional CompressOrcDataStreams( device_span compressed_data, uint32_t num_compressed_blocks, - CompressionKind compression, + compression_type compression, uint32_t comp_blk_size, uint32_t max_comp_blk_size, uint32_t comp_block_align, @@ -1387,47 +1384,7 @@ std::optional CompressOrcDataStreams( max_comp_blk_size, comp_block_align); - if (compression == SNAPPY) { - try { - if (nvcomp::is_compression_disabled(nvcomp::compression_type::SNAPPY)) { - cudf::io::detail::gpu_snap(comp_in, comp_out, comp_res, stream); - } else { - nvcomp::batched_compress( - nvcomp::compression_type::SNAPPY, comp_in, comp_out, comp_res, stream); - } - } catch (...) { - // There was an error in compressing so set an error status for each block - thrust::for_each( - rmm::exec_policy(stream), - comp_res.begin(), - comp_res.end(), - [] __device__(compression_result & stat) { stat.status = compression_status::FAILURE; }); - // Since SNAPPY is the default compression (may not be explicitly requested), fall back to - // writing without compression - CUDF_LOG_WARN("ORC writer: compression failed, writing uncompressed data"); - } - } else if (compression == ZLIB) { - if (auto const reason = nvcomp::is_compression_disabled(nvcomp::compression_type::DEFLATE); - reason) { - CUDF_FAIL("Compression error: " + reason.value()); - } - nvcomp::batched_compress( - nvcomp::compression_type::DEFLATE, comp_in, comp_out, comp_res, stream); - } else if (compression == ZSTD) { - if (auto const reason = nvcomp::is_compression_disabled(nvcomp::compression_type::ZSTD); - reason) { - CUDF_FAIL("Compression error: " + reason.value()); - } - nvcomp::batched_compress(nvcomp::compression_type::ZSTD, comp_in, comp_out, comp_res, stream); - } else if (compression == LZ4) { - if (auto const reason = nvcomp::is_compression_disabled(nvcomp::compression_type::LZ4); - reason) { - CUDF_FAIL("Compression error: " + reason.value()); - } - nvcomp::batched_compress(nvcomp::compression_type::LZ4, comp_in, comp_out, comp_res, stream); - } else if (compression != NONE) { - CUDF_FAIL("Unsupported compression type"); - } + cudf::io::detail::compress(compression, comp_in, comp_out, comp_res, stream); dim3 dim_block_compact(1024, 1); gpuCompactCompressedBlocks<<>>( diff --git a/cpp/src/io/orc/writer_impl.cu b/cpp/src/io/orc/writer_impl.cu index ce868b83c04..aa0b509981a 100644 --- a/cpp/src/io/orc/writer_impl.cu +++ b/cpp/src/io/orc/writer_impl.cu @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2024, NVIDIA CORPORATION. + * Copyright (c) 2019-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ * @brief cuDF-IO ORC writer class implementation */ -#include "io/comp/nvcomp_adapter.hpp" #include "io/orc/orc_gpu.hpp" #include "io/statistics/column_statistics.cuh" #include "io/utilities/column_utils.cuh" @@ -71,8 +70,6 @@ namespace cudf::io::orc::detail { -namespace nvcomp = cudf::io::detail::nvcomp; - template [[nodiscard]] constexpr int varint_size(T val) { @@ -92,21 +89,8 @@ struct row_group_index_info { }; namespace { - /** - * @brief Translates ORC compression to nvCOMP compression - */ -auto to_nvcomp_compression_type(CompressionKind compression_kind) -{ - if (compression_kind == SNAPPY) return nvcomp::compression_type::SNAPPY; - if (compression_kind == ZLIB) return nvcomp::compression_type::DEFLATE; - if (compression_kind == ZSTD) return nvcomp::compression_type::ZSTD; - if (compression_kind == LZ4) return nvcomp::compression_type::LZ4; - CUDF_FAIL("Unsupported compression type"); -} - -/** - * @brief Translates cuDF compression to ORC compression + * @brief Translates cuDF compression to ORC compression. */ orc::CompressionKind to_orc_compression(compression_type compression) { @@ -122,19 +106,14 @@ orc::CompressionKind to_orc_compression(compression_type compression) } /** - * @brief Returns the block size for a given compression kind. + * @brief Returns the block size for a given compression format. */ -constexpr size_t compression_block_size(orc::CompressionKind compression) +size_t compression_block_size(compression_type compression) { - if (compression == orc::CompressionKind::NONE) { return 0; } - - auto const ncomp_type = to_nvcomp_compression_type(compression); - auto const nvcomp_limit = nvcomp::is_compression_disabled(ncomp_type) - ? std::nullopt - : nvcomp::compress_max_allowed_chunk_size(ncomp_type); + auto const comp_limit = compress_max_allowed_chunk_size(compression); constexpr size_t max_block_size = 256 * 1024; - return std::min(nvcomp_limit.value_or(max_block_size), max_block_size); + return std::min(comp_limit.value_or(max_block_size), max_block_size); } /** @@ -534,26 +513,6 @@ size_t RLE_stream_size(TypeKind kind, size_t count) } } -auto uncomp_block_alignment(CompressionKind compression_kind) -{ - if (compression_kind == NONE or - nvcomp::is_compression_disabled(to_nvcomp_compression_type(compression_kind))) { - return 1ul; - } - - return nvcomp::required_alignment(to_nvcomp_compression_type(compression_kind)); -} - -auto comp_block_alignment(CompressionKind compression_kind) -{ - if (compression_kind == NONE or - nvcomp::is_compression_disabled(to_nvcomp_compression_type(compression_kind))) { - return 1ul; - } - - return nvcomp::required_alignment(to_nvcomp_compression_type(compression_kind)); -} - /** * @brief Builds up per-column streams. * @@ -566,7 +525,7 @@ orc_streams create_streams(host_span columns, file_segmentation const& segmentation, std::map const& decimal_column_sizes, bool enable_dictionary, - CompressionKind compression_kind, + compression_type compression, single_write_mode write_mode) { // 'column 0' row index stream @@ -610,7 +569,7 @@ orc_streams create_streams(host_span columns, auto add_stream = [&](gpu::StreamIndexType index_type, StreamKind kind, TypeKind type_kind, size_t size) { - auto const max_alignment_padding = uncomp_block_alignment(compression_kind) - 1; + auto const max_alignment_padding = compress_required_chunk_alignment(compression) - 1; const auto base = column.index() * gpu::CI_NUM_STREAMS; ids[base + index_type] = streams.size(); streams.push_back(orc::Stream{ @@ -1473,7 +1432,7 @@ encoded_footer_statistics finish_statistic_blobs(Footer const& footer, * @param[in] rg_stats row group level statistics * @param[in,out] stripe Stream's parent stripe * @param[in,out] streams List of all streams - * @param[in] compression_kind The compression kind + * @param[in] compression The compression format * @param[in] compression_blocksize The block size used for compression * @param[in] out_sink Sink for writing data */ @@ -1487,7 +1446,7 @@ void write_index_stream(int32_t stripe_id, host_span rg_stats, StripeInformation* stripe, orc_streams* streams, - CompressionKind compression_kind, + compression_type compression, size_t compression_blocksize, std::unique_ptr const& out_sink) { @@ -1501,7 +1460,7 @@ void write_index_stream(int32_t stripe_id, row_group_index_info record; if (stream.ids[type] > 0) { record.pos = 0; - if (compression_kind != NONE) { + if (compression != compression_type::NONE) { auto const& ss = strm_desc[stripe_id][stream.ids[type] - (columns.size() + 1)]; record.blk_pos = ss.first_block; record.comp_pos = 0; @@ -1541,7 +1500,7 @@ void write_index_stream(int32_t stripe_id, } } - ProtobufWriter pbw((compression_kind != NONE) ? 3 : 0); + ProtobufWriter pbw((compression != compression_type::NONE) ? 3 : 0); // Add row index entries auto const& rowgroups_range = segmentation.stripes[stripe_id]; @@ -1566,7 +1525,7 @@ void write_index_stream(int32_t stripe_id, }); (*streams)[stream_id].length = pbw.size(); - if (compression_kind != NONE) { + if (compression != compression_type::NONE) { uint32_t uncomp_ix_len = (uint32_t)((*streams)[stream_id].length - 3) * 2 + 1; pbw.buffer()[0] = static_cast(uncomp_ix_len >> 0); pbw.buffer()[1] = static_cast(uncomp_ix_len >> 8); @@ -1585,7 +1544,7 @@ void write_index_stream(int32_t stripe_id, * @param[in,out] bounce_buffer Pinned memory bounce buffer for D2H data transfer * @param[in,out] stripe Stream's parent stripe * @param[in,out] streams List of all streams - * @param[in] compression_kind The compression kind + * @param[in] compression The compression format * @param[in] out_sink Sink for writing data * @param[in] stream CUDA stream used for device memory operations and kernel launches * @return An std::future that should be synchronized to ensure the writing is complete @@ -1596,7 +1555,7 @@ std::future write_data_stream(gpu::StripeStream const& strm_desc, host_span bounce_buffer, StripeInformation* stripe, orc_streams* streams, - CompressionKind compression_kind, + compression_type compression, std::unique_ptr const& out_sink, rmm::cuda_stream_view stream) { @@ -1606,8 +1565,9 @@ std::future write_data_stream(gpu::StripeStream const& strm_desc, return std::async(std::launch::deferred, [] {}); } - auto const* stream_in = (compression_kind == NONE) ? enc_stream.data_ptrs[strm_desc.stream_type] - : (compressed_data + strm_desc.bfr_offset); + auto const* stream_in = (compression == compression_type::NONE) + ? enc_stream.data_ptrs[strm_desc.stream_type] + : (compressed_data + strm_desc.bfr_offset); auto write_task = [&]() { if (out_sink->is_device_write_preferred(length)) { @@ -1627,15 +1587,15 @@ std::future write_data_stream(gpu::StripeStream const& strm_desc, /** * @brief Insert 3-byte uncompressed block headers in a byte vector * - * @param compression_kind The compression kind + * @param compression The compression kind * @param compression_blocksize The block size used for compression * @param v The destitation byte vector to write, which must include initial 3-byte header */ -void add_uncompressed_block_headers(CompressionKind compression_kind, +void add_uncompressed_block_headers(compression_type compression, size_t compression_blocksize, std::vector& v) { - if (compression_kind != NONE) { + if (compression != compression_type::NONE) { size_t uncomp_len = v.size() - 3, pos = 0, block_len; while (uncomp_len > compression_blocksize) { block_len = compression_blocksize * 2 + 1; @@ -2021,14 +1981,6 @@ std::map decimal_column_sizes( return column_sizes; } -size_t max_compression_output_size(CompressionKind compression_kind, uint32_t compression_blocksize) -{ - if (compression_kind == NONE) return 0; - - return nvcomp::compress_max_output_chunk_size(to_nvcomp_compression_type(compression_kind), - compression_blocksize); -} - std::unique_ptr make_table_meta(table_view const& input) { auto table_meta = std::make_unique(input); @@ -2287,7 +2239,7 @@ stripe_dictionaries build_dictionaries(orc_table_view& orc_table, * @param row_index_stride The row index stride * @param enable_dictionary Whether dictionary is enabled * @param sort_dictionaries Whether to sort the dictionaries - * @param compression_kind The compression kind + * @param compression The compression format * @param compression_blocksize The block size used for compression * @param stats_freq Column statistics granularity type for parquet/orc writers * @param collect_compression_stats Flag to indicate if compression statistics should be collected @@ -2302,7 +2254,7 @@ auto convert_table_to_orc_data(table_view const& input, size_type row_index_stride, bool enable_dictionary, bool sort_dictionaries, - CompressionKind compression_kind, + compression_type compression, size_t compression_blocksize, statistics_freq stats_freq, bool collect_compression_stats, @@ -2329,17 +2281,16 @@ auto convert_table_to_orc_data(table_view const& input, auto stripe_dicts = build_dictionaries(orc_table, segmentation, sort_dictionaries, stream); auto dec_chunk_sizes = decimal_chunk_sizes(orc_table, segmentation, stream); - auto const uncompressed_block_align = uncomp_block_alignment(compression_kind); - auto const compressed_block_align = comp_block_alignment(compression_kind); + auto const block_align = compress_required_chunk_alignment(compression); auto streams = create_streams(orc_table.columns, segmentation, decimal_column_sizes(dec_chunk_sizes.rg_sizes), enable_dictionary, - compression_kind, + compression, write_mode); auto enc_data = encode_columns( - orc_table, std::move(dec_chunk_sizes), segmentation, streams, uncompressed_block_align, stream); + orc_table, std::move(dec_chunk_sizes), segmentation, streams, block_align, stream); stripe_dicts.on_encode_complete(stream); @@ -2371,16 +2322,15 @@ auto convert_table_to_orc_data(table_view const& input, size_t compressed_bfr_size = 0; size_t num_compressed_blocks = 0; - auto const max_compressed_block_size = - max_compression_output_size(compression_kind, compression_blocksize); + auto const max_compressed_block_size = max_compressed_size(compression, compression_blocksize); auto const padded_max_compressed_block_size = - util::round_up_unsafe(max_compressed_block_size, compressed_block_align); + util::round_up_unsafe(max_compressed_block_size, block_align); auto const padded_block_header_size = - util::round_up_unsafe(block_header_size, compressed_block_align); + util::round_up_unsafe(block_header_size, block_align); for (auto& ss : strm_descs.host_view().flat_view()) { size_t stream_size = ss.stream_size; - if (compression_kind != NONE) { + if (compression != compression_type::NONE) { ss.first_block = num_compressed_blocks; ss.bfr_offset = compressed_bfr_size; @@ -2401,14 +2351,14 @@ auto convert_table_to_orc_data(table_view const& input, comp_results.d_begin(), comp_results.d_end(), compression_result{0, compression_status::FAILURE}); - if (compression_kind != NONE) { + if (compression != compression_type::NONE) { strm_descs.host_to_device_async(stream); compression_stats = gpu::CompressOrcDataStreams(compressed_data, num_compressed_blocks, - compression_kind, + compression, compression_blocksize, max_compressed_block_size, - compressed_block_align, + block_align, collect_compression_stats, strm_descs, enc_data.streams, @@ -2459,8 +2409,8 @@ writer::impl::impl(std::unique_ptr sink, : _stream(stream), _max_stripe_size{options.get_stripe_size_bytes(), options.get_stripe_size_rows()}, _row_index_stride{options.get_row_index_stride()}, - _compression_kind(to_orc_compression(options.get_compression())), - _compression_blocksize(compression_block_size(_compression_kind)), + _compression{options.get_compression()}, + _compression_blocksize(compression_block_size(_compression)), _compression_statistics(options.get_compression_statistics()), _stats_freq(options.get_statistics_freq()), _sort_dictionaries{options.get_enable_dictionary_sort()}, @@ -2480,8 +2430,8 @@ writer::impl::impl(std::unique_ptr sink, : _stream(stream), _max_stripe_size{options.get_stripe_size_bytes(), options.get_stripe_size_rows()}, _row_index_stride{options.get_row_index_stride()}, - _compression_kind(to_orc_compression(options.get_compression())), - _compression_blocksize(compression_block_size(_compression_kind)), + _compression{options.get_compression()}, + _compression_blocksize(compression_block_size(_compression)), _compression_statistics(options.get_compression_statistics()), _stats_freq(options.get_statistics_freq()), _sort_dictionaries{options.get_enable_dictionary_sort()}, @@ -2526,7 +2476,7 @@ void writer::impl::write(table_view const& input) _row_index_stride, _enable_dictionary, _sort_dictionaries, - _compression_kind, + _compression, _compression_blocksize, _stats_freq, _compression_statistics != nullptr, @@ -2613,7 +2563,7 @@ void writer::impl::write_orc_data_to_sink(encoded_data const& enc_data, rg_stats, &stripe, &streams, - _compression_kind, + _compression, _compression_blocksize, _out_sink); } @@ -2627,7 +2577,7 @@ void writer::impl::write_orc_data_to_sink(encoded_data const& enc_data, bounce_buffer, &stripe, &streams, - _compression_kind, + _compression, _out_sink, _stream)); } @@ -2645,10 +2595,10 @@ void writer::impl::write_orc_data_to_sink(encoded_data const& enc_data, : 0; if (orc_table.column(i - 1).orc_kind() == TIMESTAMP) { sf.writerTimezone = "UTC"; } } - ProtobufWriter pbw((_compression_kind != NONE) ? 3 : 0); + ProtobufWriter pbw((_compression != compression_type::NONE) ? 3 : 0); pbw.write(sf); stripe.footerLength = pbw.size(); - if (_compression_kind != NONE) { + if (_compression != compression_type::NONE) { uint32_t uncomp_sf_len = (stripe.footerLength - 3) * 2 + 1; pbw.buffer()[0] = static_cast(uncomp_sf_len >> 0); pbw.buffer()[1] = static_cast(uncomp_sf_len >> 8); @@ -2780,21 +2730,21 @@ void writer::impl::close() // Write statistics metadata if (not _orc_meta.stripeStats.empty()) { - ProtobufWriter pbw((_compression_kind != NONE) ? 3 : 0); + ProtobufWriter pbw((_compression != compression_type::NONE) ? 3 : 0); pbw.write(_orc_meta); - add_uncompressed_block_headers(_compression_kind, _compression_blocksize, pbw.buffer()); + add_uncompressed_block_headers(_compression, _compression_blocksize, pbw.buffer()); ps.metadataLength = pbw.size(); _out_sink->host_write(pbw.data(), pbw.size()); } else { ps.metadataLength = 0; } - ProtobufWriter pbw((_compression_kind != NONE) ? 3 : 0); + ProtobufWriter pbw((_compression != compression_type::NONE) ? 3 : 0); pbw.write(_footer); - add_uncompressed_block_headers(_compression_kind, _compression_blocksize, pbw.buffer()); + add_uncompressed_block_headers(_compression, _compression_blocksize, pbw.buffer()); // Write postscript metadata ps.footerLength = pbw.size(); - ps.compression = _compression_kind; + ps.compression = to_orc_compression(_compression); ps.compressionBlockSize = _compression_blocksize; ps.version = {0, 12}; // Hive 0.12 ps.writerVersion = cudf_writer_version; diff --git a/cpp/src/io/orc/writer_impl.hpp b/cpp/src/io/orc/writer_impl.hpp index cae849ee315..7d23482cb17 100644 --- a/cpp/src/io/orc/writer_impl.hpp +++ b/cpp/src/io/orc/writer_impl.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2024, NVIDIA CORPORATION. + * Copyright (c) 2019-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -342,7 +342,7 @@ class writer::impl { // Writer options. stripe_size_limits const _max_stripe_size; size_type const _row_index_stride; - CompressionKind const _compression_kind; + compression_type const _compression; size_t const _compression_blocksize; std::shared_ptr _compression_statistics; // Optional output statistics_freq const _stats_freq; diff --git a/cpp/src/io/parquet/writer_impl.cu b/cpp/src/io/parquet/writer_impl.cu index 77924ac0f35..1b67b53ae8e 100644 --- a/cpp/src/io/parquet/writer_impl.cu +++ b/cpp/src/io/parquet/writer_impl.cu @@ -23,8 +23,7 @@ #include "compact_protocol_reader.hpp" #include "compact_protocol_writer.hpp" #include "interop/decimal_conversion_utilities.cuh" -#include "io/comp/gpuinflate.hpp" -#include "io/comp/nvcomp_adapter.hpp" +#include "io/comp/comp.hpp" #include "io/parquet/parquet.hpp" #include "io/parquet/parquet_gpu.hpp" #include "io/statistics/column_statistics.cuh" @@ -67,6 +66,20 @@ namespace cudf::io::parquet::detail { using namespace cudf::io::detail; +Compression to_parquet_compression(compression_type compression) +{ + switch (compression) { + case compression_type::AUTO: + case compression_type::SNAPPY: return Compression::SNAPPY; + case compression_type::ZSTD: return Compression::ZSTD; + case compression_type::LZ4: + // Parquet refers to LZ4 as "LZ4_RAW"; Parquet's "LZ4" is not standard LZ4 + return Compression::LZ4_RAW; + case compression_type::NONE: return Compression::UNCOMPRESSED; + default: CUDF_FAIL("Unsupported compression type"); + } +} + struct aggregate_writer_metadata { aggregate_writer_metadata(host_span partitions, host_span const> kv_md, @@ -1172,7 +1185,7 @@ auto init_page_sizes(hostdevice_2dvector& chunks, size_t max_page_size_bytes, size_type max_page_size_rows, bool write_v2_headers, - Compression compression_codec, + compression_type compression, rmm::cuda_stream_view stream) { if (chunks.is_empty()) { return cudf::detail::hostdevice_vector{}; } @@ -1187,7 +1200,7 @@ auto init_page_sizes(hostdevice_2dvector& chunks, num_columns, max_page_size_bytes, max_page_size_rows, - page_alignment(compression_codec), + compress_required_chunk_alignment(compression), write_v2_headers, nullptr, nullptr, @@ -1212,7 +1225,7 @@ auto init_page_sizes(hostdevice_2dvector& chunks, num_columns, max_page_size_bytes, max_page_size_rows, - page_alignment(compression_codec), + compress_required_chunk_alignment(compression), write_v2_headers, nullptr, nullptr, @@ -1221,12 +1234,10 @@ auto init_page_sizes(hostdevice_2dvector& chunks, // Get per-page max compressed size cudf::detail::hostdevice_vector comp_page_sizes(num_pages, stream); - std::transform(page_sizes.begin(), - page_sizes.end(), - comp_page_sizes.begin(), - [compression_codec](auto page_size) { - return max_compression_output_size(compression_codec, page_size); - }); + std::transform( + page_sizes.begin(), page_sizes.end(), comp_page_sizes.begin(), [compression](auto page_size) { + return max_compressed_size(compression, page_size); + }); comp_page_sizes.host_to_device_async(stream); // Use per-page max compressed size to calculate chunk.compressed_size @@ -1238,7 +1249,7 @@ auto init_page_sizes(hostdevice_2dvector& chunks, num_columns, max_page_size_bytes, max_page_size_rows, - page_alignment(compression_codec), + compress_required_chunk_alignment(compression), write_v2_headers, nullptr, nullptr, @@ -1247,16 +1258,13 @@ auto init_page_sizes(hostdevice_2dvector& chunks, return comp_page_sizes; } -size_t max_page_bytes(Compression compression, size_t max_page_size_bytes) +size_t max_page_bytes(compression_type compression, size_t max_page_size_bytes) { - if (compression == Compression::UNCOMPRESSED) { return max_page_size_bytes; } + if (compression == compression_type::NONE) { return max_page_size_bytes; } - auto const ncomp_type = to_nvcomp_compression_type(compression); - auto const nvcomp_limit = nvcomp::is_compression_disabled(ncomp_type) - ? std::nullopt - : nvcomp::compress_max_allowed_chunk_size(ncomp_type); + auto const comp_limit = compress_max_allowed_chunk_size(compression); - auto max_size = std::min(nvcomp_limit.value_or(max_page_size_bytes), max_page_size_bytes); + auto max_size = std::min(comp_limit.value_or(max_page_size_bytes), max_page_size_bytes); // page size must fit in a 32-bit signed integer return std::min(max_size, std::numeric_limits::max()); } @@ -1265,7 +1273,7 @@ std::pair>, std::vector& chunks, host_span col_desc, device_2dspan frags, - Compression compression, + compression_type compression, dictionary_policy dict_policy, size_t max_dict_size, rmm::cuda_stream_view stream) @@ -1404,7 +1412,7 @@ build_chunk_dictionaries(hostdevice_2dvector& chunks, * @param num_columns Total number of columns * @param num_pages Total number of pages * @param num_stats_bfr Number of statistics buffers - * @param compression Compression format + * @param alignment Page alignment * @param max_page_size_bytes Maximum uncompressed page size, in bytes * @param max_page_size_rows Maximum page size, in rows * @param write_v2_headers True if version 2 page headers are to be written @@ -1419,7 +1427,7 @@ void init_encoder_pages(hostdevice_2dvector& chunks, uint32_t num_columns, uint32_t num_pages, uint32_t num_stats_bfr, - Compression compression, + size_t alignment, size_t max_page_size_bytes, size_type max_page_size_rows, bool write_v2_headers, @@ -1435,7 +1443,7 @@ void init_encoder_pages(hostdevice_2dvector& chunks, num_columns, max_page_size_bytes, max_page_size_rows, - page_alignment(compression), + alignment, write_v2_headers, (num_stats_bfr) ? page_stats_mrg.data() : nullptr, (num_stats_bfr > num_pages) ? page_stats_mrg.data() + num_pages : nullptr, @@ -1478,7 +1486,7 @@ void encode_pages(hostdevice_2dvector& chunks, statistics_chunk const* chunk_stats, statistics_chunk const* column_stats, std::optional& comp_stats, - Compression compression, + compression_type compression, int32_t column_index_truncate_length, bool write_v2_headers, rmm::cuda_stream_view stream) @@ -1488,7 +1496,7 @@ void encode_pages(hostdevice_2dvector& chunks, ? device_span(page_stats, num_pages) : device_span(); - uint32_t max_comp_pages = (compression != Compression::UNCOMPRESSED) ? num_pages : 0; + uint32_t max_comp_pages = (compression != compression_type::NONE) ? num_pages : 0; rmm::device_uvector> comp_in(max_comp_pages, stream); rmm::device_uvector> comp_out(max_comp_pages, stream); @@ -1499,34 +1507,7 @@ void encode_pages(hostdevice_2dvector& chunks, compression_result{0, compression_status::FAILURE}); EncodePages(pages, write_v2_headers, comp_in, comp_out, comp_res, stream); - switch (compression) { - case Compression::SNAPPY: - if (nvcomp::is_compression_disabled(nvcomp::compression_type::SNAPPY)) { - gpu_snap(comp_in, comp_out, comp_res, stream); - } else { - nvcomp::batched_compress( - nvcomp::compression_type::SNAPPY, comp_in, comp_out, comp_res, stream); - } - break; - case Compression::ZSTD: { - if (auto const reason = nvcomp::is_compression_disabled(nvcomp::compression_type::ZSTD); - reason) { - CUDF_FAIL("Compression error: " + reason.value()); - } - nvcomp::batched_compress(nvcomp::compression_type::ZSTD, comp_in, comp_out, comp_res, stream); - break; - } - case Compression::LZ4_RAW: { - if (auto const reason = nvcomp::is_compression_disabled(nvcomp::compression_type::LZ4); - reason) { - CUDF_FAIL("Compression error: " + reason.value()); - } - nvcomp::batched_compress(nvcomp::compression_type::LZ4, comp_in, comp_out, comp_res, stream); - break; - } - case Compression::UNCOMPRESSED: break; - default: CUDF_FAIL("invalid compression type"); - } + compress(compression, comp_in, comp_out, comp_res, stream); // TBD: Not clear if the official spec actually allows dynamically turning off compression at the // chunk-level @@ -1744,7 +1725,7 @@ auto convert_table_to_parquet_data(table_input_metadata& table_meta, size_type max_page_size_rows, int32_t column_index_truncate_length, statistics_freq stats_granularity, - Compression compression, + compression_type compression, bool collect_compression_statistics, dictionary_policy dict_policy, size_t max_dictionary_size, @@ -2146,7 +2127,7 @@ auto convert_table_to_parquet_data(table_input_metadata& table_meta, } // Clear compressed buffer size if compression has been turned off - if (compression == Compression::UNCOMPRESSED) { max_comp_bfr_size = 0; } + if (compression == compression_type::NONE) { max_comp_bfr_size = 0; } // Initialize data pointers uint32_t const num_stats_bfr = @@ -2214,7 +2195,7 @@ auto convert_table_to_parquet_data(table_input_metadata& table_meta, num_columns, num_pages, num_stats_bfr, - compression, + compress_required_chunk_alignment(compression), max_page_size_bytes, max_page_size_rows, write_v2_headers, @@ -2270,7 +2251,7 @@ auto convert_table_to_parquet_data(table_input_metadata& table_meta, auto const dev_bfr = ck.is_compressed ? ck.compressed_bfr : ck.uncompressed_bfr; auto& column_chunk_meta = row_group.columns[i].meta_data; - if (ck.is_compressed) { column_chunk_meta.codec = compression; } + if (ck.is_compressed) { column_chunk_meta.codec = to_parquet_compression(compression); } if (!out_sink[p]->is_device_write_preferred(ck.compressed_size)) { all_device_write = false; } @@ -2375,7 +2356,7 @@ writer::impl::impl(std::vector> sinks, single_write_mode mode, rmm::cuda_stream_view stream) : _stream(stream), - _compression(to_parquet_compression(options.get_compression())), + _compression(options.get_compression()), _max_row_group_size{options.get_row_group_size_bytes()}, _max_row_group_rows{options.get_row_group_size_rows()}, _max_page_size_bytes(max_page_bytes(_compression, options.get_max_page_size_bytes())), @@ -2406,7 +2387,7 @@ writer::impl::impl(std::vector> sinks, single_write_mode mode, rmm::cuda_stream_view stream) : _stream(stream), - _compression(to_parquet_compression(options.get_compression())), + _compression(options.get_compression()), _max_row_group_size{options.get_row_group_size_bytes()}, _max_row_group_rows{options.get_row_group_size_rows()}, _max_page_size_bytes(max_page_bytes(_compression, options.get_max_page_size_bytes())), diff --git a/cpp/src/io/parquet/writer_impl.hpp b/cpp/src/io/parquet/writer_impl.hpp index 63128faf993..d5a5a534b93 100644 --- a/cpp/src/io/parquet/writer_impl.hpp +++ b/cpp/src/io/parquet/writer_impl.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2024, NVIDIA CORPORATION. + * Copyright (c) 2019-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -144,7 +144,7 @@ class writer::impl { rmm::cuda_stream_view _stream; // Writer options. - Compression const _compression; + compression_type const _compression; size_t const _max_row_group_size; size_type const _max_row_group_rows; size_t const _max_page_size_bytes; diff --git a/cpp/src/io/parquet/writer_impl_helpers.cpp b/cpp/src/io/parquet/writer_impl_helpers.cpp index f15ea1f3c37..ede788c97c2 100644 --- a/cpp/src/io/parquet/writer_impl_helpers.cpp +++ b/cpp/src/io/parquet/writer_impl_helpers.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, NVIDIA CORPORATION. + * Copyright (c) 2024-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,8 +21,6 @@ #include "writer_impl_helpers.hpp" -#include "io/comp/nvcomp_adapter.hpp" - #include #include #include @@ -32,48 +30,6 @@ namespace cudf::io::parquet::detail { using namespace cudf::io::detail; -Compression to_parquet_compression(compression_type compression) -{ - switch (compression) { - case compression_type::AUTO: - case compression_type::SNAPPY: return Compression::SNAPPY; - case compression_type::ZSTD: return Compression::ZSTD; - case compression_type::LZ4: - // Parquet refers to LZ4 as "LZ4_RAW"; Parquet's "LZ4" is not standard LZ4 - return Compression::LZ4_RAW; - case compression_type::NONE: return Compression::UNCOMPRESSED; - default: CUDF_FAIL("Unsupported compression type"); - } -} - -nvcomp::compression_type to_nvcomp_compression_type(Compression codec) -{ - switch (codec) { - case Compression::SNAPPY: return nvcomp::compression_type::SNAPPY; - case Compression::ZSTD: return nvcomp::compression_type::ZSTD; - // Parquet refers to LZ4 as "LZ4_RAW"; Parquet's "LZ4" is not standard LZ4 - case Compression::LZ4_RAW: return nvcomp::compression_type::LZ4; - default: CUDF_FAIL("Unsupported compression type"); - } -} - -uint32_t page_alignment(Compression codec) -{ - if (codec == Compression::UNCOMPRESSED or - nvcomp::is_compression_disabled(to_nvcomp_compression_type(codec))) { - return 1u; - } - - return nvcomp::required_alignment(to_nvcomp_compression_type(codec)); -} - -size_t max_compression_output_size(Compression codec, uint32_t compression_blocksize) -{ - if (codec == Compression::UNCOMPRESSED) return 0; - - return compress_max_output_chunk_size(to_nvcomp_compression_type(codec), compression_blocksize); -} - void fill_table_meta(table_input_metadata& table_meta) { // Fill unnamed columns' names in table_meta diff --git a/cpp/src/io/parquet/writer_impl_helpers.hpp b/cpp/src/io/parquet/writer_impl_helpers.hpp index 14a9a0ed5b7..b5c73c348fe 100644 --- a/cpp/src/io/parquet/writer_impl_helpers.hpp +++ b/cpp/src/io/parquet/writer_impl_helpers.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, NVIDIA CORPORATION. + * Copyright (c) 2024-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,48 +20,12 @@ */ #pragma once -#include "parquet_common.hpp" #include #include -#include namespace cudf::io::parquet::detail { -/** - * @brief Function that translates GDF compression to parquet compression. - * - * @param compression The compression type - * @return The supported Parquet compression - */ -Compression to_parquet_compression(compression_type compression); - -/** - * @brief Function that translates the given compression codec to nvcomp compression type. - * - * @param codec Compression codec - * @return Translated nvcomp compression type - */ -cudf::io::detail::nvcomp::compression_type to_nvcomp_compression_type(Compression codec); - -/** - * @brief Function that computes input alignment requirements for the given compression type. - * - * @param codec Compression codec - * @return Required alignment - */ -uint32_t page_alignment(Compression codec); - -/** - * @brief Gets the maximum compressed chunk size for the largest chunk uncompressed chunk in the - * batch. - * - * @param codec Compression codec - * @param compression_blocksize Size of the largest uncompressed chunk in the batch - * @return Maximum compressed chunk size - */ -size_t max_compression_output_size(Compression codec, uint32_t compression_blocksize); - /** * @brief Fill the table metadata with default column names. * diff --git a/cpp/tests/io/orc_test.cpp b/cpp/tests/io/orc_test.cpp index 2209a30149d..708c2045a74 100644 --- a/cpp/tests/io/orc_test.cpp +++ b/cpp/tests/io/orc_test.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2024, NVIDIA CORPORATION. + * Copyright (c) 2019-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -2068,6 +2068,7 @@ TEST_P(OrcCompressionTest, Basic) INSTANTIATE_TEST_CASE_P(OrcCompressionTest, OrcCompressionTest, ::testing::Values(cudf::io::compression_type::NONE, + cudf::io::compression_type::AUTO, cudf::io::compression_type::SNAPPY, cudf::io::compression_type::LZ4, cudf::io::compression_type::ZSTD)); diff --git a/cpp/tests/io/parquet_misc_test.cpp b/cpp/tests/io/parquet_misc_test.cpp index d66f685cd9c..419ac909ac6 100644 --- a/cpp/tests/io/parquet_misc_test.cpp +++ b/cpp/tests/io/parquet_misc_test.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023-2024, NVIDIA CORPORATION. + * Copyright (c) 2023-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -268,6 +268,7 @@ TEST_P(ParquetCompressionTest, Basic) INSTANTIATE_TEST_CASE_P(ParquetCompressionTest, ParquetCompressionTest, ::testing::Values(cudf::io::compression_type::NONE, + cudf::io::compression_type::AUTO, cudf::io::compression_type::SNAPPY, cudf::io::compression_type::LZ4, cudf::io::compression_type::ZSTD)); From 478ec50edf302a338db043039abad6a2560144ea Mon Sep 17 00:00:00 2001 From: Bradley Dice Date: Mon, 13 Jan 2025 15:19:44 -0600 Subject: [PATCH 2/7] Precompute AST arity (#17234) This PR precomputes AST arity on the host, to reduce the complexity in device-side arity lookup. Authors: - Bradley Dice (https://github.com/bdice) - Basit Ayantunde (https://github.com/lamarrr) Approvers: - Basit Ayantunde (https://github.com/lamarrr) - Kyle Edwards (https://github.com/KyleFromNVIDIA) URL: https://github.com/rapidsai/cudf/pull/17234 --- cpp/CMakeLists.txt | 1 + .../cudf/ast/detail/expression_evaluator.cuh | 4 +- .../cudf/ast/detail/expression_parser.hpp | 50 ++- cpp/include/cudf/ast/detail/operators.hpp | 418 +++--------------- cpp/src/ast/expression_parser.cpp | 3 +- cpp/src/ast/operators.cpp | 293 ++++++++++++ 6 files changed, 391 insertions(+), 378 deletions(-) create mode 100644 cpp/src/ast/operators.cpp diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 252cc7897d8..4d83cbd907c 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -336,6 +336,7 @@ add_library( src/aggregation/result_cache.cpp src/ast/expression_parser.cpp src/ast/expressions.cpp + src/ast/operators.cpp src/binaryop/binaryop.cpp src/binaryop/compiled/ATan2.cu src/binaryop/compiled/Add.cu diff --git a/cpp/include/cudf/ast/detail/expression_evaluator.cuh b/cpp/include/cudf/ast/detail/expression_evaluator.cuh index 9d8762555d7..001b604814c 100644 --- a/cpp/include/cudf/ast/detail/expression_evaluator.cuh +++ b/cpp/include/cudf/ast/detail/expression_evaluator.cuh @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2024, NVIDIA CORPORATION. + * Copyright (c) 2021-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -452,7 +452,7 @@ struct expression_evaluator { ++operator_index) { // Execute operator auto const op = plan.operators[operator_index]; - auto const arity = ast_operator_arity(op); + auto const arity = plan.operator_arities[operator_index]; if (arity == 1) { // Unary operator auto const& input = diff --git a/cpp/include/cudf/ast/detail/expression_parser.hpp b/cpp/include/cudf/ast/detail/expression_parser.hpp index b5973d0ace9..d2e8c1cd41f 100644 --- a/cpp/include/cudf/ast/detail/expression_parser.hpp +++ b/cpp/include/cudf/ast/detail/expression_parser.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024, NVIDIA CORPORATION. + * Copyright (c) 2020-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -88,6 +89,7 @@ struct expression_device_view { device_span data_references; device_span literals; device_span operators; + device_span operator_arities; device_span operator_source_indices; cudf::size_type num_intermediates; }; @@ -229,39 +231,55 @@ class expression_parser { * @param[in] v The `std::vector` containing components (operators, literals, etc). * @param[in,out] sizes The `std::vector` containing the size of each data buffer. * @param[in,out] data_pointers The `std::vector` containing pointers to each data buffer. + * @param[in,out] alignment The maximum alignment needed for all the extracted size and pointers */ template void extract_size_and_pointer(std::vector const& v, std::vector& sizes, - std::vector& data_pointers) + std::vector& data_pointers, + cudf::size_type& alignment) { + // sub-type alignment will only work provided the alignment is lesser or equal to + // alignof(max_align_t) which is the maximum alignment provided by rmm's device buffers + static_assert(alignof(T) <= alignof(max_align_t)); auto const data_size = sizeof(T) * v.size(); sizes.push_back(data_size); data_pointers.push_back(v.data()); + alignment = std::max(alignment, static_cast(alignof(T))); } void move_to_device(rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { std::vector sizes; std::vector data_pointers; + // use a minimum of 4-byte alignment + cudf::size_type buffer_alignment = 4; - extract_size_and_pointer(_data_references, sizes, data_pointers); - extract_size_and_pointer(_literals, sizes, data_pointers); - extract_size_and_pointer(_operators, sizes, data_pointers); - extract_size_and_pointer(_operator_source_indices, sizes, data_pointers); + extract_size_and_pointer(_data_references, sizes, data_pointers, buffer_alignment); + extract_size_and_pointer(_literals, sizes, data_pointers, buffer_alignment); + extract_size_and_pointer(_operators, sizes, data_pointers, buffer_alignment); + extract_size_and_pointer(_operator_arities, sizes, data_pointers, buffer_alignment); + extract_size_and_pointer(_operator_source_indices, sizes, data_pointers, buffer_alignment); // Create device buffer - auto const buffer_size = std::accumulate(sizes.cbegin(), sizes.cend(), 0); - auto buffer_offsets = std::vector(sizes.size()); - thrust::exclusive_scan(sizes.cbegin(), sizes.cend(), buffer_offsets.begin(), 0); + auto buffer_offsets = std::vector(sizes.size()); + thrust::exclusive_scan(sizes.cbegin(), + sizes.cend(), + buffer_offsets.begin(), + cudf::size_type{0}, + [buffer_alignment](auto a, auto b) { + // align each component of the AST program + return cudf::util::round_up_safe(a + b, buffer_alignment); + }); + + auto const buffer_size = buffer_offsets.empty() ? 0 : (buffer_offsets.back() + sizes.back()); + auto host_data_buffer = std::vector(buffer_size); - auto h_data_buffer = std::vector(buffer_size); for (unsigned int i = 0; i < data_pointers.size(); ++i) { - std::memcpy(h_data_buffer.data() + buffer_offsets[i], data_pointers[i], sizes[i]); + std::memcpy(host_data_buffer.data() + buffer_offsets[i], data_pointers[i], sizes[i]); } - _device_data_buffer = rmm::device_buffer(h_data_buffer.data(), buffer_size, stream, mr); - + _device_data_buffer = rmm::device_buffer(host_data_buffer.data(), buffer_size, stream, mr); stream.synchronize(); // Create device pointers to components of plan @@ -277,8 +295,11 @@ class expression_parser { device_expression_data.operators = device_span( reinterpret_cast(device_data_buffer_ptr + buffer_offsets[2]), _operators.size()); - device_expression_data.operator_source_indices = device_span( + device_expression_data.operator_arities = device_span( reinterpret_cast(device_data_buffer_ptr + buffer_offsets[3]), + _operators.size()); + device_expression_data.operator_source_indices = device_span( + reinterpret_cast(device_data_buffer_ptr + buffer_offsets[4]), _operator_source_indices.size()); device_expression_data.num_intermediates = _intermediate_counter.get_max_used(); shmem_per_thread = static_cast( @@ -322,6 +343,7 @@ class expression_parser { bool _has_nulls; std::vector _data_references; std::vector _operators; + std::vector _operator_arities; std::vector _operator_source_indices; std::vector _literals; }; diff --git a/cpp/include/cudf/ast/detail/operators.hpp b/cpp/include/cudf/ast/detail/operators.hpp index 46507700e21..db04e1fe989 100644 --- a/cpp/include/cudf/ast/detail/operators.hpp +++ b/cpp/include/cudf/ast/detail/operators.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024, NVIDIA CORPORATION. + * Copyright (c) 2020-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,159 +69,111 @@ constexpr bool is_valid_unary_op = cuda::std::is_invocable_v; * @param args Forwarded arguments to `operator()` of `f`. */ template -CUDF_HOST_DEVICE inline constexpr void ast_operator_dispatcher(ast_operator op, F&& f, Ts&&... args) +CUDF_HOST_DEVICE inline constexpr decltype(auto) ast_operator_dispatcher(ast_operator op, + F&& f, + Ts&&... args) { switch (op) { case ast_operator::ADD: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::SUB: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::MUL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::DIV: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::TRUE_DIV: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::FLOOR_DIV: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::MOD: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::PYMOD: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::POW: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::EQUAL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::NULL_EQUAL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::NOT_EQUAL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::LESS: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::GREATER: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::LESS_EQUAL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::GREATER_EQUAL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::BITWISE_AND: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::BITWISE_OR: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::BITWISE_XOR: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::LOGICAL_AND: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::NULL_LOGICAL_AND: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::LOGICAL_OR: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::NULL_LOGICAL_OR: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::IDENTITY: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::IS_NULL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::SIN: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::COS: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::TAN: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::ARCSIN: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::ARCCOS: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::ARCTAN: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::SINH: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::COSH: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::TANH: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::ARCSINH: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::ARCCOSH: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::ARCTANH: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::EXP: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::LOG: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::SQRT: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::CBRT: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::CEIL: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::FLOOR: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::ABS: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::RINT: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::BIT_INVERT: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::NOT: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::CAST_TO_INT64: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::CAST_TO_UINT64: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); case ast_operator::CAST_TO_FLOAT64: - f.template operator()(std::forward(args)...); - break; + return f.template operator()(std::forward(args)...); default: { #ifndef __CUDA_ARCH__ CUDF_FAIL("Invalid operator."); @@ -955,231 +907,6 @@ struct operator_functor { } }; -/** - * @brief Functor used to single-type-dispatch binary operators. - * - * This functor's `operator()` is templated to validate calls to its operators based on the input - * type, as determined by the `is_valid_binary_op` trait. This function assumes that both inputs are - * the same type, and dispatches based on the type of the left input. - * - * @tparam OperatorFunctor Binary operator functor. - */ -template -struct single_dispatch_binary_operator_types { - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(F&& f, Ts&&... args) - { - f.template operator()(std::forward(args)...); - } - - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(F&& f, Ts&&... args) - { -#ifndef __CUDA_ARCH__ - CUDF_FAIL("Invalid binary operation."); -#else - CUDF_UNREACHABLE("Invalid binary operation."); -#endif - } -}; - -/** - * @brief Functor performing a type dispatch for a binary operator. - * - * This functor performs single dispatch, which assumes lhs_type == rhs_type. This may not be true - * for all binary operators but holds for all currently implemented operators. - */ -struct type_dispatch_binary_op { - /** - * @brief Performs type dispatch for a binary operator. - * - * @tparam op AST operator. - * @tparam F Type of forwarded functor. - * @tparam Ts Parameter pack of forwarded arguments. - * @param lhs_type Type of left input data. - * @param rhs_type Type of right input data. - * @param f Forwarded functor to be called. - * @param args Forwarded arguments to `operator()` of `f`. - */ - template - CUDF_HOST_DEVICE inline void operator()(cudf::data_type lhs_type, - cudf::data_type rhs_type, - F&& f, - Ts&&... args) - { - // Single dispatch (assume lhs_type == rhs_type) - type_dispatcher( - lhs_type, - // Always dispatch to the non-null operator for the purpose of type determination. - detail::single_dispatch_binary_operator_types>{}, - std::forward(f), - std::forward(args)...); - } -}; - -/** - * @brief Dispatches a runtime binary operator to a templated type dispatcher. - * - * @tparam F Type of forwarded functor. - * @tparam Ts Parameter pack of forwarded arguments. - * @param lhs_type Type of left input data. - * @param rhs_type Type of right input data. - * @param f Forwarded functor to be called. - * @param args Forwarded arguments to `operator()` of `f`. - */ -template -CUDF_HOST_DEVICE inline constexpr void binary_operator_dispatcher( - ast_operator op, cudf::data_type lhs_type, cudf::data_type rhs_type, F&& f, Ts&&... args) -{ - ast_operator_dispatcher(op, - detail::type_dispatch_binary_op{}, - lhs_type, - rhs_type, - std::forward(f), - std::forward(args)...); -} - -/** - * @brief Functor used to type-dispatch unary operators. - * - * This functor's `operator()` is templated to validate calls to its operators based on the input - * type, as determined by the `is_valid_unary_op` trait. - * - * @tparam OperatorFunctor Unary operator functor. - */ -template -struct dispatch_unary_operator_types { - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(F&& f, Ts&&... args) - { - f.template operator()(std::forward(args)...); - } - - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(F&& f, Ts&&... args) - { -#ifndef __CUDA_ARCH__ - CUDF_FAIL("Invalid unary operation."); -#else - CUDF_UNREACHABLE("Invalid unary operation."); -#endif - } -}; - -/** - * @brief Functor performing a type dispatch for a unary operator. - */ -struct type_dispatch_unary_op { - template - CUDF_HOST_DEVICE inline void operator()(cudf::data_type input_type, F&& f, Ts&&... args) - { - type_dispatcher( - input_type, - // Always dispatch to the non-null operator for the purpose of type determination. - detail::dispatch_unary_operator_types>{}, - std::forward(f), - std::forward(args)...); - } -}; - -/** - * @brief Dispatches a runtime unary operator to a templated type dispatcher. - * - * @tparam F Type of forwarded functor. - * @tparam Ts Parameter pack of forwarded arguments. - * @param input_type Type of input data. - * @param f Forwarded functor to be called. - * @param args Forwarded arguments to `operator()` of `f`. - */ -template -CUDF_HOST_DEVICE inline constexpr void unary_operator_dispatcher(ast_operator op, - cudf::data_type input_type, - F&& f, - Ts&&... args) -{ - ast_operator_dispatcher(op, - detail::type_dispatch_unary_op{}, - input_type, - std::forward(f), - std::forward(args)...); -} - -/** - * @brief Functor to determine the return type of an operator from its input types. - */ -struct return_type_functor { - /** - * @brief Callable for binary operators to determine return type. - * - * @tparam OperatorFunctor Operator functor to perform. - * @tparam LHS Left input type. - * @tparam RHS Right input type. - * @param result Reference whose value is assigned to the result data type. - */ - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(cudf::data_type& result) - { - using Out = cuda::std::invoke_result_t; - result = cudf::data_type(cudf::type_to_id()); - } - - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(cudf::data_type& result) - { -#ifndef __CUDA_ARCH__ - CUDF_FAIL("Invalid binary operation. Return type cannot be determined."); -#else - CUDF_UNREACHABLE("Invalid binary operation. Return type cannot be determined."); -#endif - } - - /** - * @brief Callable for unary operators to determine return type. - * - * @tparam OperatorFunctor Operator functor to perform. - * @tparam T Input type. - * @param result Pointer whose value is assigned to the result data type. - */ - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(cudf::data_type& result) - { - using Out = cuda::std::invoke_result_t; - result = cudf::data_type(cudf::type_to_id()); - } - - template >* = nullptr> - CUDF_HOST_DEVICE inline void operator()(cudf::data_type& result) - { -#ifndef __CUDA_ARCH__ - CUDF_FAIL("Invalid unary operation. Return type cannot be determined."); -#else - CUDF_UNREACHABLE("Invalid unary operation. Return type cannot be determined."); -#endif - } -}; - /** * @brief Gets the return type of an AST operator. * @@ -1187,34 +914,8 @@ struct return_type_functor { * @param operand_types Vector of input types to the operator. * @return cudf::data_type Return type of the operator. */ -inline cudf::data_type ast_operator_return_type(ast_operator op, - std::vector const& operand_types) -{ - auto result = cudf::data_type(cudf::type_id::EMPTY); - switch (operand_types.size()) { - case 1: - unary_operator_dispatcher(op, operand_types[0], detail::return_type_functor{}, result); - break; - case 2: - binary_operator_dispatcher( - op, operand_types[0], operand_types[1], detail::return_type_functor{}, result); - break; - default: CUDF_FAIL("Unsupported operator return type."); break; - } - return result; -} - -/** - * @brief Functor to determine the arity (number of operands) of an operator. - */ -struct arity_functor { - template - CUDF_HOST_DEVICE inline void operator()(cudf::size_type& result) - { - // Arity is not dependent on null handling, so just use the false implementation here. - result = operator_functor::arity; - } -}; +cudf::data_type ast_operator_return_type(ast_operator op, + std::vector const& operand_types); /** * @brief Gets the arity (number of operands) of an AST operator. @@ -1222,12 +923,7 @@ struct arity_functor { * @param op Operator used to determine arity. * @return Arity of the operator. */ -CUDF_HOST_DEVICE inline cudf::size_type ast_operator_arity(ast_operator op) -{ - auto result = cudf::size_type(0); - ast_operator_dispatcher(op, detail::arity_functor{}, result); - return result; -} +cudf::size_type ast_operator_arity(ast_operator op); } // namespace detail diff --git a/cpp/src/ast/expression_parser.cpp b/cpp/src/ast/expression_parser.cpp index d0e4c59ca54..b2cc134d9fa 100644 --- a/cpp/src/ast/expression_parser.cpp +++ b/cpp/src/ast/expression_parser.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024, NVIDIA CORPORATION. + * Copyright (c) 2020-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -161,6 +161,7 @@ cudf::size_type expression_parser::visit(operation const& expr) auto const op = expr.get_operator(); auto const data_type = cudf::ast::detail::ast_operator_return_type(op, operand_types); _operators.push_back(op); + _operator_arities.push_back(cudf::ast::detail::ast_operator_arity(op)); // Push data reference auto const output = [&]() { if (expression_index == 0) { diff --git a/cpp/src/ast/operators.cpp b/cpp/src/ast/operators.cpp new file mode 100644 index 00000000000..b60a69a42d9 --- /dev/null +++ b/cpp/src/ast/operators.cpp @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2021-2025, 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 + +namespace cudf { +namespace ast { +namespace detail { +namespace { + +struct arity_functor { + template + void operator()(cudf::size_type& result) + { + // Arity is not dependent on null handling, so just use the false implementation here. + result = operator_functor::arity; + } +}; + +/** + * @brief Functor to determine the return type of an operator from its input types. + */ +struct return_type_functor { + /** + * @brief Callable for binary operators to determine return type. + * + * @tparam OperatorFunctor Operator functor to perform. + * @tparam LHS Left input type. + * @tparam RHS Right input type. + * @param result Pointer whose value is assigned to the result data type. + */ + template >* = nullptr> + void operator()(cudf::data_type& result) + { + using Out = cuda::std::invoke_result_t; + result = cudf::data_type{cudf::type_to_id()}; + } + + template >* = nullptr> + void operator()(cudf::data_type& result) + { +#ifndef __CUDA_ARCH__ + CUDF_FAIL("Invalid binary operation. Return type cannot be determined."); +#else + CUDF_UNREACHABLE("Invalid binary operation. Return type cannot be determined."); +#endif + result = cudf::data_type{cudf::type_id::EMPTY}; + } + + /** + * @brief Callable for unary operators to determine return type. + * + * @tparam OperatorFunctor Operator functor to perform. + * @tparam T Input type. + * @param result Pointer whose value is assigned to the result data type. + */ + template >* = nullptr> + void operator()(cudf::data_type& result) + { + using Out = cuda::std::invoke_result_t; + result = cudf::data_type{cudf::type_to_id()}; + } + + template >* = nullptr> + void operator()(cudf::data_type& result) + { +#ifndef __CUDA_ARCH__ + CUDF_FAIL("Invalid unary operation. Return type cannot be determined."); +#else + CUDF_UNREACHABLE("Invalid unary operation. Return type cannot be determined."); +#endif + result = cudf::data_type{cudf::type_id::EMPTY}; + } +}; + +/** + * @brief Functor used to single-type-dispatch binary operators. + * + * This functor's `operator()` is templated to validate calls to its operators based on the input + * type, as determined by the `is_valid_binary_op` trait. This function assumes that both inputs are + * the same type, and dispatches based on the type of the left input. + * + * @tparam OperatorFunctor Binary operator functor. + */ +template +struct single_dispatch_binary_operator_types { + template >* = nullptr> + inline void operator()(F&& f, Ts&&... args) + { + f.template operator()(std::forward(args)...); + } + + template >* = nullptr> + inline void operator()(F&& f, Ts&&... args) + { +#ifndef __CUDA_ARCH__ + CUDF_FAIL("Invalid binary operation."); +#else + CUDF_UNREACHABLE("Invalid binary operation."); +#endif + } +}; + +/** + * @brief Functor performing a type dispatch for a binary operator. + * + * This functor performs single dispatch, which assumes lhs_type == rhs_type. This may not be true + * for all binary operators but holds for all currently implemented operators. + */ +struct type_dispatch_binary_op { + /** + * @brief Performs type dispatch for a binary operator. + * + * @tparam op AST operator. + * @tparam F Type of forwarded functor. + * @tparam Ts Parameter pack of forwarded arguments. + * @param lhs_type Type of left input data. + * @param rhs_type Type of right input data. + * @param f Forwarded functor to be called. + * @param args Forwarded arguments to `operator()` of `f`. + */ + template + inline void operator()(cudf::data_type lhs_type, cudf::data_type rhs_type, F&& f, Ts&&... args) + { + // Single dispatch (assume lhs_type == rhs_type) + type_dispatcher( + lhs_type, + // Always dispatch to the non-null operator for the purpose of type determination. + detail::single_dispatch_binary_operator_types>{}, + std::forward(f), + std::forward(args)...); + } +}; + +/** + * @brief Dispatches a runtime binary operator to a templated type dispatcher. + * + * @tparam F Type of forwarded functor. + * @tparam Ts Parameter pack of forwarded arguments. + * @param lhs_type Type of left input data. + * @param rhs_type Type of right input data. + * @param f Forwarded functor to be called. + * @param args Forwarded arguments to `operator()` of `f`. + */ +template +inline constexpr void binary_operator_dispatcher( + ast_operator op, cudf::data_type lhs_type, cudf::data_type rhs_type, F&& f, Ts&&... args) +{ + ast_operator_dispatcher(op, + detail::type_dispatch_binary_op{}, + lhs_type, + rhs_type, + std::forward(f), + std::forward(args)...); +} + +/** + * @brief Functor used to type-dispatch unary operators. + * + * This functor's `operator()` is templated to validate calls to its operators based on the input + * type, as determined by the `is_valid_unary_op` trait. + * + * @tparam OperatorFunctor Unary operator functor. + */ +template +struct dispatch_unary_operator_types { + template >* = nullptr> + inline void operator()(F&& f, Ts&&... args) + { + f.template operator()(std::forward(args)...); + } + + template >* = nullptr> + inline void operator()(F&& f, Ts&&... args) + { +#ifndef __CUDA_ARCH__ + CUDF_FAIL("Invalid unary operation."); +#else + CUDF_UNREACHABLE("Invalid unary operation."); +#endif + } +}; + +/** + * @brief Functor performing a type dispatch for a unary operator. + */ +struct type_dispatch_unary_op { + template + inline void operator()(cudf::data_type input_type, F&& f, Ts&&... args) + { + type_dispatcher( + input_type, + // Always dispatch to the non-null operator for the purpose of type determination. + detail::dispatch_unary_operator_types>{}, + std::forward(f), + std::forward(args)...); + } +}; + +/** + * @brief Dispatches a runtime unary operator to a templated type dispatcher. + * + * @tparam F Type of forwarded functor. + * @tparam Ts Parameter pack of forwarded arguments. + * @param input_type Type of input data. + * @param f Forwarded functor to be called. + * @param args Forwarded arguments to `operator()` of `f`. + */ +template +inline constexpr void unary_operator_dispatcher(ast_operator op, + cudf::data_type input_type, + F&& f, + Ts&&... args) +{ + ast_operator_dispatcher(op, + detail::type_dispatch_unary_op{}, + input_type, + std::forward(f), + std::forward(args)...); +} + +} // namespace + +cudf::data_type ast_operator_return_type(ast_operator op, + std::vector const& operand_types) +{ + cudf::data_type result{cudf::type_id::EMPTY}; + switch (operand_types.size()) { + case 1: + unary_operator_dispatcher(op, operand_types[0], detail::return_type_functor{}, result); + break; + case 2: + binary_operator_dispatcher( + op, operand_types[0], operand_types[1], detail::return_type_functor{}, result); + break; + default: CUDF_FAIL("Unsupported operator return type."); break; + } + return result; +} + +cudf::size_type ast_operator_arity(ast_operator op) +{ + cudf::size_type result{}; + ast_operator_dispatcher(op, arity_functor{}, result); + return result; +} + +} // namespace detail + +} // namespace ast + +} // namespace cudf From f84cd4316eaa61e231b5fd096608ca09d5e3c08c Mon Sep 17 00:00:00 2001 From: Matthew Murray <41342305+Matt711@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:26:43 -0500 Subject: [PATCH 3/7] [BUG] xfail Polars excel test (#17731) One the Polars tests fails when `fastexcel>=0.12.1`. I opened https://github.com/pola-rs/polars/issues/20698 to track that failing test. This PR xfail that test for now. xref #17677 Authors: - Matthew Murray (https://github.com/Matt711) Approvers: - GALI PREM SAGAR (https://github.com/galipremsagar) URL: https://github.com/rapidsai/cudf/pull/17731 --- python/cudf_polars/cudf_polars/testing/plugin.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/python/cudf_polars/cudf_polars/testing/plugin.py b/python/cudf_polars/cudf_polars/testing/plugin.py index c16df320ceb..e453a8b89b9 100644 --- a/python/cudf_polars/cudf_polars/testing/plugin.py +++ b/python/cudf_polars/cudf_polars/testing/plugin.py @@ -8,7 +8,9 @@ from functools import partialmethod from typing import TYPE_CHECKING +import fastexcel import pytest +from packaging import version import polars @@ -44,7 +46,7 @@ def pytest_configure(config: pytest.Config) -> None: ) -EXPECTED_FAILURES: Mapping[str, str] = { +EXPECTED_FAILURES: Mapping[str, str | tuple[str, bool]] = { "tests/unit/io/test_csv.py::test_compressed_csv": "Need to determine if file is compressed", "tests/unit/io/test_csv.py::test_read_csv_only_loads_selected_columns": "Memory usage won't be correct due to GPU", "tests/unit/io/test_delta.py::test_scan_delta_version": "Need to expose hive partitioning", @@ -192,6 +194,10 @@ def pytest_configure(config: pytest.Config) -> None: # Maybe flaky, order-dependent? "tests/unit/test_projections.py::test_schema_full_outer_join_projection_pd_13287": "Order-specific result check, query is correct but in different order", "tests/unit/test_queries.py::test_group_by_agg_equals_zero_3535": "libcudf sums all nulls to null, not zero", + "tests/unit/io/test_spreadsheet.py::test_write_excel_bytes[calamine]": ( + "Fails when fastexcel version >= 0.12.1. tracking issue: https://github.com/pola-rs/polars/issues/20698", + version.parse(fastexcel.__version__) >= version.parse("0.12.1"), + ), } @@ -219,4 +225,12 @@ def pytest_collection_modifyitems( if item.nodeid in TESTS_TO_SKIP: item.add_marker(pytest.mark.skip(reason=TESTS_TO_SKIP[item.nodeid])) elif item.nodeid in EXPECTED_FAILURES: + if isinstance(EXPECTED_FAILURES[item.nodeid], tuple): + # the second entry in the tuple is the condition to xfail on + item.add_marker( + pytest.mark.xfail( + condition=EXPECTED_FAILURES[item.nodeid][1], + reason=EXPECTED_FAILURES[item.nodeid][0], + ), + ) item.add_marker(pytest.mark.xfail(reason=EXPECTED_FAILURES[item.nodeid])) From 253fb2f10e921519502e562672d29029e844c2cf Mon Sep 17 00:00:00 2001 From: Nghia Truong <7416935+ttnghia@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:41:47 -0800 Subject: [PATCH 4/7] Require to implement `AutoCloseable` for the classes derived from `HostUDFWrapper` (#17727) This adds the requirement to implement `AutoCloseable` to the classes derived from `HostUDFWrapper`, forcing them to delete the native UDF instance upon class destruction. Doing so will fix the memory leak issue when the native UDF instance never being destroyed. Authors: - Nghia Truong (https://github.com/ttnghia) Approvers: - Robert (Bobby) Evans (https://github.com/revans2) URL: https://github.com/rapidsai/cudf/pull/17727 --- java/src/main/java/ai/rapids/cudf/HostUDFWrapper.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/java/src/main/java/ai/rapids/cudf/HostUDFWrapper.java b/java/src/main/java/ai/rapids/cudf/HostUDFWrapper.java index 0b6ecf2e140..124f2c99188 100644 --- a/java/src/main/java/ai/rapids/cudf/HostUDFWrapper.java +++ b/java/src/main/java/ai/rapids/cudf/HostUDFWrapper.java @@ -24,8 +24,10 @@ *

* A new host UDF aggregation implementation must extend this class and override the * {@code hashCode} and {@code equals} methods for such purposes. + * In addition, since this class implements {@code AutoCloseable}, the {@code close} method must + * also be overridden to automatically delete the native UDF instance upon class destruction. */ -public abstract class HostUDFWrapper { +public abstract class HostUDFWrapper implements AutoCloseable { public final long udfNativeHandle; public HostUDFWrapper(long udfNativeHandle) { From 847029172ce47ef2109dc825f149ab58130a2fc6 Mon Sep 17 00:00:00 2001 From: GALI PREM SAGAR Date: Tue, 14 Jan 2025 09:18:13 -0600 Subject: [PATCH 5/7] convert all nulls to nans in a specific scenario (#17677) Fixes: #17666 This PR ensures we convert all nulls to nan's in float columns only in pandas compatibility mode. Authors: - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - Matthew Roeschke (https://github.com/mroeschke) - Matthew Murray (https://github.com/Matt711) URL: https://github.com/rapidsai/cudf/pull/17677 --- python/cudf/cudf/core/column/column.py | 4 ++++ python/cudf/cudf/tests/test_series.py | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/python/cudf/cudf/core/column/column.py b/python/cudf/cudf/core/column/column.py index 30da8727366..19f2802553d 100644 --- a/python/cudf/cudf/core/column/column.py +++ b/python/cudf/cudf/core/column/column.py @@ -2413,7 +2413,11 @@ def as_column( and pa.types.is_integer(arbitrary.type) and arbitrary.null_count > 0 ): + # TODO: Need to re-visit this cast and fill_null + # calls while addressing the following issue: + # https://github.com/rapidsai/cudf/issues/14149 arbitrary = arbitrary.cast(pa.float64()) + arbitrary = pc.fill_null(arbitrary, np.nan) if ( cudf.get_option("default_integer_bitwidth") and pa.types.is_integer(arbitrary.type) diff --git a/python/cudf/cudf/tests/test_series.py b/python/cudf/cudf/tests/test_series.py index f8697c5c6b8..891c0ede9a4 100644 --- a/python/cudf/cudf/tests/test_series.py +++ b/python/cudf/cudf/tests/test_series.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2024, NVIDIA CORPORATION. +# Copyright (c) 2020-2025, NVIDIA CORPORATION. import datetime import decimal import hashlib @@ -3003,3 +3003,12 @@ def test_dtype_dtypes_equal(): ser = cudf.Series([0]) assert ser.dtype is ser.dtypes assert ser.dtypes is ser.to_pandas().dtypes + + +def test_null_like_to_nan_pandas_compat(): + with cudf.option_context("mode.pandas_compatible", True): + ser = cudf.Series([1, 2, np.nan, 10, None]) + pser = pd.Series([1, 2, np.nan, 10, None]) + + assert pser.dtype == ser.dtype + assert_eq(ser, pser) From fe75cb8779b91f08f2ed410c0ad133b328939775 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Tue, 14 Jan 2025 15:15:11 -0500 Subject: [PATCH 6/7] Update kvikio call due to upstream changes (#17733) KvikIO has just introduced slightly breaking code changes (https://github.com/rapidsai/kvikio/pull/581) where the utility function `getenv_or` is moved out of the `detail` namespace and becomes part of the public API. This PR adapts cuDF for this change. This PR has been tested locally using the abovementioned KvikIO branch. Authors: - Tianyu Liu (https://github.com/kingcrimsontianyu) - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - Devavret Makkar (https://github.com/devavret) - Bradley Dice (https://github.com/bdice) - Muhammad Haseeb (https://github.com/mhaseeb123) URL: https://github.com/rapidsai/cudf/pull/17733 --- cpp/src/io/utilities/config_utils.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cpp/src/io/utilities/config_utils.cpp b/cpp/src/io/utilities/config_utils.cpp index cea0ebad8f5..726feca328b 100644 --- a/cpp/src/io/utilities/config_utils.cpp +++ b/cpp/src/io/utilities/config_utils.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2024, NVIDIA CORPORATION. + * Copyright (c) 2021-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -56,8 +56,7 @@ void set_up_kvikio() { static std::once_flag flag{}; std::call_once(flag, [] { - auto const compat_mode = - kvikio::detail::getenv_or("KVIKIO_COMPAT_MODE", kvikio::CompatMode::ON); + auto const compat_mode = kvikio::getenv_or("KVIKIO_COMPAT_MODE", kvikio::CompatMode::ON); kvikio::defaults::compat_mode_reset(compat_mode); auto const nthreads = getenv_or("KVIKIO_NTHREADS", 4u); From 41215e254c6eac9f94a12fbeeccfeceaa4526200 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Tue, 14 Jan 2025 12:35:15 -0800 Subject: [PATCH 7/7] Support reading bloom filters from Parquet files and filter row groups using them (#17289) This PR adds support to read bloom filters from Parquet files and use them to filter row groups based on `col == literal` like predicate(s), if provided. Related to #17164 Authors: - Muhammad Haseeb (https://github.com/mhaseeb123) Approvers: - Yunsong Wang (https://github.com/PointKernel) - Vukasin Milovanovic (https://github.com/vuule) - Karthikeyan (https://github.com/karthikeyann) - Bradley Dice (https://github.com/bdice) URL: https://github.com/rapidsai/cudf/pull/17289 --- cpp/CMakeLists.txt | 1 + cpp/src/io/parquet/bloom_filter_reader.cu | 683 ++++++++++++++++++ .../io/parquet/compact_protocol_reader.cpp | 35 +- .../io/parquet/compact_protocol_reader.hpp | 6 +- cpp/src/io/parquet/parquet.hpp | 52 +- cpp/src/io/parquet/predicate_pushdown.cpp | 136 ++-- cpp/src/io/parquet/reader_impl_helpers.cpp | 5 +- cpp/src/io/parquet/reader_impl_helpers.hpp | 90 ++- cpp/src/io/parquet/reader_impl_preprocess.cu | 5 +- cpp/tests/CMakeLists.txt | 5 +- cpp/tests/io/parquet_bloom_filter_test.cu | 90 +++ ...d_ndv_100_bf_fpp0.1_nostats.snappy.parquet | Bin 0 -> 41508 bytes ...ed_card_ndv_100_chunk_stats.snappy.parquet | Bin 0 -> 37036 bytes ...d_ndv_500_bf_fpp0.1_nostats.snappy.parquet | Bin 0 -> 77985 bytes ...ed_card_ndv_500_chunk_stats.snappy.parquet | Bin 0 -> 58150 bytes python/cudf/cudf/tests/test_parquet.py | 57 +- 16 files changed, 1098 insertions(+), 67 deletions(-) create mode 100644 cpp/src/io/parquet/bloom_filter_reader.cu create mode 100644 cpp/tests/io/parquet_bloom_filter_test.cu 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/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 4d83cbd907c..354560998c5 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -516,6 +516,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 new file mode 100644 index 00000000000..8c404950efa --- /dev/null +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -0,0 +1,683 @@ +/* + * Copyright (c) 2025, 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 +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace cudf::io::parquet::detail { +namespace { + +/** + * @brief Converts bloom filter membership results (for each column chunk) to a device column. + * + */ +struct bloom_filter_caster { + cudf::device_span const> bloom_filter_spans; + host_span parquet_types; + size_t total_row_groups; + size_t num_equality_columns; + + 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, + rmm::cuda_stream_view stream) const + { + using key_type = T; + using policy_type = cuco::arrow_filter_policy; + using word_type = typename policy_type::word_type; + + // Boolean, List, Struct, Dictionary types are not supported + if constexpr (std::is_same_v or + (cudf::is_compound() and not std::is_same_v)) { + CUDF_FAIL("Bloom filters do not support boolean or compound types"); + } else { + // Check if the literal has the same type as the predicate column + CUDF_EXPECTS( + dtype == literal->get_data_type() and + cudf::have_same_types( + cudf::column_view{dtype, 0, {}, {}, 0, 0, {}}, + cudf::scalar_type_t(T{}, false, stream, cudf::get_current_device_resource_ref())), + "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, cudf::get_current_device_resource_ref()}; + cudf::device_span results_span{static_cast(results.data()), total_row_groups}; + + // Query literal in bloom filters from each column chunk (row group). + thrust::tabulate( + rmm::exec_policy_nosync(stream), + 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] __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) { return true; } + + // Number of filter blocks + auto const num_filter_blocks = filter_size / bytes_per_block; + + // Create a bloom filter view. + cuco::bloom_filter_ref, + cuco::thread_scope_thread, + policy_type> + 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::hashing::detail::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 + 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}; + return filter.contains(probe_key); + } else { + // Query the bloom filter and store results + return filter.contains(d_scalar.value()); + } + }); + + return std::make_unique(cudf::data_type{cudf::type_id::BOOL8}, + static_cast(total_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, + cudf::data_type dtype, + ast::literal* const literal, + rmm::cuda_stream_view stream) const + { + // 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); + } + } + + // For all other cases + return query_bloom_filter(equality_col_idx, dtype, literal, stream); + } +}; + +/** + * @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 { + public: + equality_literals_collector() = default; + + equality_literals_collector(ast::expression const& expr, cudf::size_type 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 + { + 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"); + 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 BloomfilterAST"); + } + + /** + * @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"); + 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(literal_ptr)); + } + } else { + // Just visit the operands and ignore any output + std::ignore = visit_operands(operands); + } + + return expr; + } + + /** + * @brief Vectors of equality literals in the AST expression, one per input table column + * + * @return Vectors of equality literals, one per input table column + */ + [[nodiscard]] std::vector> get_equality_literals() && + { + return std::move(_equality_literals); + } + + private: + std::vector> _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; + } + 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, + cudf::host_span const> equality_literals) + : _equality_literals{equality_literals} + { + // Set the num columns + _num_input_columns = num_input_columns; + + // 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 it's not needed in the derived class + */ + [[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 = _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) { + _bloom_filter_expr.push(ast::operation{op, new_operands.front(), new_operands.back()}); + } else if (cudf::ast::detail::ast_operator_arity(op) == 1) { + _bloom_filter_expr.push(ast::operation{op, new_operands.front()}); + } + } + return _bloom_filter_expr.back(); + } + + /** + * @brief Returns the AST to apply on bloom filter membership. + * + * @return AST operation expression + */ + [[nodiscard]] std::reference_wrapper get_bloom_filter_expr() const + { + 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}; +}; + +/** + * @brief Reads bloom filter data to device. + * + * @param sources Dataset sources + * @param num_chunks Number of total column chunks to read + * @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 + * @param stream CUDA stream used for device memory operations and kernel launches + */ +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; + + // Read bloom filters for all column chunks + std::for_each( + 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()) { + bloom_filter_data[chunk] = {}; + 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); + + // 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 % 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; + } + + // Bloom filter header size + auto const bloom_filter_header_size = static_cast(cp.bytecount()); + 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) { + 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 + for (auto& task : read_tasks) { + task.wait(); + } +} + +} // 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 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 = total_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; + + // 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()), + [&](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; + + // 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++; + }); + }); + }); + + // Exit early if we don't have any bloom filters + if (not have_bloom_filters) { return {}; } + + // Vector to hold bloom filter data + std::vector bloom_filter_data(num_chunks); + + // 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; +} + +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> input_row_group_indices, + host_span output_dtypes, + host_span output_column_schemas, + 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()); + + // 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"); + + // Collect equality literals for each input table column + 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; + 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) { return not eq_literals.empty(); }); + + // 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 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); + + // 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(input_row_group_indices, equality_col_schemas); + + // 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(), + bloom_filter_data.end(), + std::back_inserter(h_bloom_filter_spans), + [&](auto& buffer) { + return cudf::device_span{ + static_cast(buffer.data()), buffer.size()}; + }); + + // Copy bloom filter bitset spans to device + auto const bloom_filter_spans = cudf::detail::make_device_uvector_async( + h_bloom_filter_spans, stream, cudf::get_current_device_resource_ref()); + + // 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::counting_iterator(0), + thrust::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 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]) { + bloom_filter_membership_columns.emplace_back(cudf::type_dispatcher( + dtype, bloom_filter_col, equality_col_idx, dtype, literal, stream)); + } + 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 + // 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); +} + +} // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/compact_protocol_reader.cpp b/cpp/src/io/parquet/compact_protocol_reader.cpp index f1ecf66c29f..b8e72aaac88 100644 --- a/cpp/src/io/parquet/compact_protocol_reader.cpp +++ b/cpp/src/io/parquet/compact_protocol_reader.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2024, NVIDIA CORPORATION. + * Copyright (c) 2018-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -658,6 +658,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 = @@ -665,7 +692,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), @@ -677,6 +706,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..360197b19ad 100644 --- a/cpp/src/io/parquet/compact_protocol_reader.hpp +++ b/cpp/src/io/parquet/compact_protocol_reader.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2024, NVIDIA CORPORATION. + * Copyright (c) 2018-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -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..dc0c4b1540e 100644 --- a/cpp/src/io/parquet/parquet.hpp +++ b/cpp/src/io/parquet/parquet.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2024, NVIDIA CORPORATION. + * Copyright (c) 2018-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -382,12 +382,62 @@ 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 class Algorithm { UNDEFINED, SPLIT_BLOCK }; + Algorithm algorithm{Algorithm::SPLIT_BLOCK}; +}; + +/** + * @brief The hash function used in Bloom filter + */ +struct BloomFilterHash { + // xxHash_64 + enum class Hash { UNDEFINED, XXHASH }; + Hash hash{Hash::XXHASH}; +}; + +/** + * @brief The compression used in the bloom filter + */ +struct BloomFilterCompression { + enum class 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 + 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/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 9047ff9169b..0e307bac097 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023-2024, NVIDIA CORPORATION. + * Copyright (c) 2023-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ #include #include +#include #include #include #include @@ -388,6 +389,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, @@ -396,7 +398,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()) { @@ -412,18 +413,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 const stats_col{total_row_groups, per_file_metadata, input_row_group_indices}; + stats_caster const 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]; @@ -452,44 +457,23 @@ std::optional>> aggregate_reader_metadata::fi 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 const filtered_row_group_indices = collect_filtered_row_group_indices( + stats_table, stats_expr.get_stats_expr(), input_row_group_indices, stream); - 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 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; - 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 (std::all_of(is_row_group_required.cbegin(), - is_row_group_required.cend(), - [](auto i) { return bool(i); }) or - predicate.null_count() == predicate.size()) { - return std::nullopt; - } - 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)); - } - return {std::move(filtered_row_group_indices)}; + // 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 @@ -510,14 +494,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; } @@ -531,7 +515,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()); } @@ -546,7 +530,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()); } @@ -640,4 +624,60 @@ 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 ast_expr, + host_span const> input_row_group_indices, + rmm::cuda_stream_view stream) +{ + // Filter the input table using AST expression + auto predicate_col = cudf::detail::compute_column( + table, ast_expr.get(), stream, cudf::get_current_device_resource_ref()); + 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); }); + + // 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 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); })) { + 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.cpp b/cpp/src/io/parquet/reader_impl_helpers.cpp index 0dd1aff41e9..25baa1e0ec8 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.cpp +++ b/cpp/src/io/parquet/reader_impl_helpers.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024, NVIDIA CORPORATION. + * Copyright (c) 2022-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1030,6 +1030,7 @@ std::vector aggregate_reader_metadata::get_pandas_index_names() con 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, @@ -1042,7 +1043,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 fd692c0cdd6..a28ce616e2c 100644 --- a/cpp/src/io/parquet/reader_impl_helpers.hpp +++ b/cpp/src/io/parquet/reader_impl_helpers.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024, NVIDIA CORPORATION. + * Copyright (c) 2022-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -195,6 +195,38 @@ 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 predicate column across + * 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; + + /** + * @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 provided 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, @@ -323,26 +355,49 @@ 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_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 - * @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, 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 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 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 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> input_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 * * 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 @@ -351,10 +406,11 @@ 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> 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, @@ -413,14 +469,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: @@ -428,7 +484,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; @@ -445,4 +501,22 @@ 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 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 + * + * @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, + std::reference_wrapper ast_expr, + host_span const> input_row_group_indices, + rmm::cuda_stream_view stream); + } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/reader_impl_preprocess.cu b/cpp/src/io/parquet/reader_impl_preprocess.cu index 326232ced60..43666f9e42d 100644 --- a/cpp/src/io/parquet/reader_impl_preprocess.cu +++ b/cpp/src/io/parquet/reader_impl_preprocess.cu @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024, NVIDIA CORPORATION. + * Copyright (c) 2022-2025, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1286,7 +1286,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, diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 35877ac34b9..6a89b1e48d6 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -318,14 +318,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/io/parquet_bloom_filter_test.cu b/cpp/tests/io/parquet_bloom_filter_test.cu new file mode 100644 index 00000000000..d858f58fa56 --- /dev/null +++ b/cpp/tests/io/parquet_bloom_filter_test.cu @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025, 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 + +#include +#include + +using StringType = cudf::string_view; + +class ParquetBloomFilterTest : public cudf::test::BaseFixture {}; + +TEST_F(ParquetBloomFilterTest, TestStrings) +{ + using key_type = StringType; + using policy_type = cuco::arrow_filter_policy; + using word_type = policy_type::word_type; + + std::size_t constexpr num_filter_blocks = 4; + auto stream = cudf::get_default_stream(); + + // 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", + "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_keys = cudf::column_device_view::create(keys); + + // 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, + {{cudf::DEFAULT_HASH_SEED}}, + 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); + + // Number of words in the filter + cudf::size_type const num_words = filter.block_extent() * filter.words_per_block; + + // 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's implementation here: + // https://godbolt.org/z/oKfqcPWbY + auto expected = cudf::test::fixed_width_column_wrapper( + {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(bitset, expected); +} 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..4123545a6e030b7c0b1b7852571348ed53074f72 GIT binary patch literal 41508 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 literal 0 HcmV?d00001 diff --git a/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_500_bf_fpp0.1_nostats.snappy.parquet b/python/cudf/cudf/tests/data/parquet/mixed_card_ndv_500_bf_fpp0.1_nostats.snappy.parquet new file mode 100644 index 0000000000000000000000000000000000000000..e898f1d7d1bf6976b661b87b7ea39b615f318102 GIT binary patch literal 77985 zcmd?Rd3;mVzW2YACS|9jB`qzqP;j@T&;mk1z#>Ri(j-mNG|kwi0klbzHffVKP13ZD z({3qHMhgNKMJAGR0Z~E0gB)j3ally*a&SBj&u10&de1q}eeUzzKYoAw`r^&^ zyS{7fY3()ay|X{tthCO`5#^kkmh{LO|IK|ZH#uWD|p4mJ3Z;r2FFyZvpNI=>)aUeo04RyCWw zW;51RU0d!m>YNq27!u}+fH%?J=4!6Pikr=9ovYhdTO;;xv4F+lYqc2-;@xs*tXv(k zo9ZlH!Bn}fCE;_JY|Vi-EZ!FCv{~%tkXDC;A*8XJn!AJk2olC{xGi9-vUYZ0#s1D{ zMAKds4T`GKMH2ZKOx;4!m{%WO0UyW7yx*OU%;=!Q5 z=ta(7*{u!xoi!TqT261Rw(0GOkg2GjYj^6*i7IWxsM*McJ+)O0zDAo@d_vw`+ZOJM zbhH~y0=qmIs%_}1iF&JPEvg&3boM}twq2aTHRxQeMrBQFxlhP-*4h)TI2@ zr!Q})!&+^XU7<=td%#$vlIxsGU1L)~-{Qbp{7H#Vg@8;U$A)~FOqf&?Uv^NAgJ&onv=AxwB)ZnW$s;#yPz2FcRuC?HF zI~7tr^yxU4D;DlD2U@M-uw1LOHq^F6yDBOKTF&Na z3F;dYI;VIl*Wj(ST0NawH#XK4ZMQhf6D``JV19eZ7BW|9GU?9j)WL&l=Z^373AgSJxBs88i=E`!6GNT_3IMxKPN#-Md{TEl`e zd25He!R$2In?k5)x5;U34pnFk;#2Ylt69}i7f`#SXl#j2b*r=0-WD#}!a42!R;8oO z>%|cmU3J#-N{dfZgQjkXS*neRaCcPm5$89yMVwYgz+AM8Yt}b)wCha{bL-KBSU2|=l*Jbqx$~aX=RdtoM!XEc+<0^d-ovo#=uBB))r?c2p z5oLwj=G((X;~1HmtqE1pNzS47xgsWGyQ=7W&fuu1>{6>jt>Szx7}UjlI(?Ie_9$DY zp+;$U`Ed09_6AG4PHPRLOr5r+rmfTDZpTr^np>L8)@XzOR+jU3mDl-#nvNRu%uZFn zT&;5?Tt)lkot^fes;Q>S-HFE4+!btUsZs^KMW5xH8{1T!HnqBB7pLrS2iq!|8asSK zxjta3QL0Q0O)f!Men{7DP55KlJG!{Knr@r9sasQlhFoK*R@ypj&MKUZNR=b*^2FMF zs8Jn6Fw;&-|m zbdGilnyjnUS*us;+YRCq`Q|`PEaD5qB_}vllg829Y!20m!<@m_;BW;zuG*p?8m7C= z=WVlDl>)8Y)?o1}&C0ORL&u4lu1IBP&}Y6?&RGr4P$bbEi{Q`#KEI>AJX+aQl;A9l zj>d$|5N;ORxkRPT)b8`tx=)|rn%Wv{{;1jD(Y(&#KVL-C8YuU@%$ZtS%WFDy<#voQ zrrI{QU+)YkvHmWdS!apaRn-`i^c4}CUav8`u>NLkOWf>>#}jCE(x{!?5=KqTf$8zexKUZ?dbODopu~TYo#@$F?CfpXQviWtO<1-EwkCs;WozHi5hg$cxOedsXMB5 zVwCMrH8p$99nr8s5ayz`*5*cwzFUQ%#o~==-Hxz2j+Is!OzlBusL`kK%heUGNJodY zq1})EQETXIsEZgZ4OwX{7brK^1*@W3_vu6OCVRBnYIPax4)pz?(xTTl8^an5p{=%< zA>fNtbo;zqWoO8xDtGxcMW1p0hHzVxEofAVf6CXIHLgx|Fm%@^TxEyPAFYcf%xDT) zZRPaOT1Q)}J6w)KOUQjql{&975^ix~@0!XhJ6i)fqYXXQ zU+oRLltHfzy{psOl&H}JJa+Utx2@IC)?!j>#Sh9Ob#bk|JK*vq(0D41o^W|E;ONu~ zGL}@-St^3wSVhQAPfAO|YYdq%iUo6+oJ}b?+ft^}>or+UYECM{JTEwNguRFn2vbDj z)Df&CZKO0kW0Wj2Yjk$bn6bHe%W8mmmzW{cHkchuB6>s*VMEWPaVdbg*+>uYTC2bx=g zq1Lu=dn6j`ho#rPa{b`e8*bco z)6KUGZQrqT*R8kRzWa_nckaFG?tAv#yZ^rXA9(PghaY+LvB#fy@~Py3gHIoN=Go_P zMY=CL=fN~%&a=YdE7HL<_EP~P7BG*bv&?C*ALNTCvJ5i=-ZbzqxHG+&sY#bHUEpS< zYrq5GCd7HzdFi=K5P4bfOW-eowZNW))xsV@-i;uFG+mzvd<33MFJ;Cc{x19vP)-1U z6*vZ-Kr92XM`6DLZ>Mw2PQ;IcgWz?<4kP~%Y&x(be8(1mmyNWpEu2{b^QPM8s} zbzmvV)BXAoTn}CbQt%S;R)9@lCU^k*NNeXIZ0ieHE9@sI*9-qB{8so0_+P-UhCd4a zHSm9gT@T*Cc78>i0e2v61)m^x6zLb?--oh=@T2fw0~5ffsMjB0X&tsB?>(>`=|tFlhMYKa+GGf8P*~5J@O*O4tJ>`72 zRQyxFT;L)Vi)W&5T82C4xZ&P;aiE!P2~G=5`n`v`hP!BJxQ{yiqm#NbGQ=A^Ttc7a5~JWo^K zpq8q^3{VKtz%h`A7X1qBCe-X!)apw36XCbRroqx4`7rD{*lS^(D04fYXOFh>5O^PF z=S|pjm11snR+W=Y@GlYE`gdKx&C9q=w?ac%&oc4}PPzk<9x)JNS2N;lc zqP!PA-A)tGfO5p&L;Nz>2LT36w zrXogrG96*MaZHO~?*J}9>o*7ZP!Cg4{(0~v(w~5hC`(7cJ|EwkLh#zCo} zpXW}|t>~?y(*g-bR@zCQM+H@(f^t!TMeu23pdGLjmNsm9vL_(#B|sZ?2{Pmh-lDtBXh4T#+bN#Xx=aUY& zbgIYxEu8vWCv^SKI->wrvLgk5zLp*Rr;FK{zhBMf{c$;4c>a2Jj)`8-R{nlPn>u6h z=p{>Qguh?b7B@CI{91ZttHh;k_V3rW9KEmSAzHRv90yl5=(z4O{ z`(^H?&A8CLXba{};r1QEA#_GQJEw1yF(-L`>g3akiG$0a6~JFVQ_M=4$kMR_nUq<1 zU5bE}67{9y#Vq^qQ!*rYUv?@?vM3>eVSL9X&=fZyM2TG2(qJzls&tv;1R|BxRz*i^Zi<=FENLkjuh8L%dJQS8UG{5SBciB`HR|@!x!iET;YgRO>Uva7!n2uj%G zH76x9QOSxTtnu`jqQfll&l1;5lY7(CX&rnh(C-#4a2BCz#!Bg-&TAFxq)bO%inv(x z=vvfdl8=fXX8Eq7qB530d>^W5{N*LDi|$-m6lRleZx9bk$<4P+z>zSs$Kwc+4;aK< zELpR@Xah^e9TtDWCd<~K=93|h6TZ-)ojxDxaJH??wm<$ z;DzEM9h-df73^?w;X6J*I`M4HCzzA}R-}`XZS6vVk|kZVPDy)fk&NY!7>dj+@9Y!n zSYGvzW*;XUctk}E}JPvC0w>t?-aeg1Ir}!twm>~ zjCwOG=)#~hN`qDIdWx1Td=e*-Z~H{-WJ$}HSmnLnVek2k8dT`#57X#@pRf(S$5X_! zRXk4WIv|Kr$o>a5zxD`4?2n z?$+XD;`)#Sd(~oyt?Mg;YU#fwd;2}+Pxs%lf4$(pcr-5f zC;X>daK&%;;OQ8%X5lV;RN?vi@G0kS#M92-i7y(y6|bT9;zi;1nYxJhxBKzY^oHDa zd1?G=z4-Ura+3)+=4Gqb%*VJjOL6U01LJUe-g=>U_zr#a@GW}&&C71d$DlQoma_*} z5QonXS_9&9*9wOO4C6;dJOVBU9;EwV_kr)g*GR7f6F~^Pg4h&T0&ar;3m6Z7HLM!6 zfn#6*u{S{!{yE?QxY1{hg4aO|EJ5sf*oCm&umP}^`ruoT4qCx9#P`BJ275nv41Ni0 z3fKhhMfw5o53m#Ir@%@0o57R7kMz~R2>(Iw8vI4Dw}Y?1O-OG6YA}HGb+Au@QqTdu zKsj3AY5?4!;ZN;E#gc4tBwhftTPHg6H8+0YUg0*d8zz2$4Px)_`85IoKP)h46m_ z3*cW1HiLB_2QekM5dJ6Nd-yxRr{KTphyVF0n1DV8%lz*=2m9^Pq4b}ggh_BApZ(`& zVRcIuFBRjup^o-K_xT56DZf7vo0k`jReLHr!nwaa7#nfb+~G%KdBe}fvhi@N9e3Sx z&OaWzw13m)3|!9_UA49Dw})gSw$E=WU9n>)`r*SPa}JI)=6rR&AD$GSdrP=Lh&GP8 zP{F=P>n4R^E(Yt69zvRhtw26)GY=#FA(#$-3G8CzU4=Z_4|l*n40{*sWtPM?URi1o=AnM#Q(lKLxhYd|29MXCb};`8l8#KJ9B4 z!;d4c4wkmR5s25r-Ug<41x z9Y$Uydy5XmF%xPFUUVQ`T81pL1^&tKTEbTw5VAmnN70b{*+k*5xpcfdy zrO2aW#6j5mU|p~;BHjf%1LZfs{(|%?uoIAfGyHXk+hDIp>?!z5>GH6D0UsmQ3I2lE z$0+jxho&G;~w9TG>wq3Nu`JbO| zPx`$x|NeNp?Dyx}SN#5fyK2qadFLN-hlelze|yLs#-;ygJm%hj=iHaw9{P_b-TvW6 z-SmbaFK2b$AMaWurRNl>tFAXmPURPpQGfp`cTsdaxysd;nsLWO()~>R;Eu6{B%|{Q zPJKi~o}HqY|4Jy2bbg;~vE~($E$b|gnA!_S_1{-)`Si0fL^o;m_mAZLLvia6EBduV zMrs75OQNSHktsg@wh_zEDC!eWs=i+-A&Z8hs*9LOWKPYfiEsaqN%~g)>YH;fLyn&# z>u0$n%kD}g+wRuIi?@v@-|tT3zEJq1LVCdj>)S7%RTTg9;-0PV9aDVNIqmlO3-ZYg z&T$v)o>4%qEPo=J+$bQm-fQ=_2vP|5)*H8&Z%QhDzIn`}buZ)*yXu9(icu_?tUX;| z_~;wON2l*9`{~_0Qm?#mx#s7wBx37dq&l5R4!jn*Ie5kQid~(To%m@@9@+A9NvZ0R zF~oM$;gO%1Gs*r96Ow10KPqIVLtD0AS4gb!W1qH`7m(eT4!yPM`tjtQ^uN#O{~YzY zMtd1J}1*IariZSzQS>ea$=J8n9wxYen;;XYF?nY{Kp)3wiKlbzR{+&iHolYFFa ztuwqiiKM=I(ZJ;AIYOSj>mOHNepYe)Ul**Jeop}*_PaBWz;y*FL-3e zhw~-m@wy`apv8Rr#ybuIP&b5JC5Ix&64_E!dtFgdQ#D6UH7-T%U@Cm zZz>p7g!54SEC0za<=MnMloxp;|0#vDYRvr~wZEuHs2}-e{~hDVC3CJ&tQwm`?z;Zf zAI{zUjl#MAskFV{vE*>EdwS#4a|&+T*r#4$Qi-egr&Q_4QRLavzKpxW0HCkjv92u;i8x&W;XUG=|{E=_eLEQb2M)4Q!h8c^+B*THBj1pUEV= zGZ5J^IHbsKuNYi5?H5HXx_-IlvOFT2_@MBki)5t!=t5KW*BoiA&YC*s!a}m*r_9(l z4~-z)u1hu^DNZNXE(jmF_N@`*vnPkH{~}{N`S$)<;XT*=O~K8+d2)7MDjE2pQ!m_D zKrVaP6@BZibkg_I{fpBZCy|XW9j(mSJBkeclAM+H1V^Sy(-&RDXOXEndwOp_`ZvX? z`Bx$dQVr{$2;$4(UtMGMK|XY-GU z-_Ie23tVYEj!DGw_UL^lug@m~j&(g{JAP7Jxnk_gOP7xzza*L$2R`S>uCuvi_kQ~~ zMR@$TbC+$OM3y}8@ji|BO-0t{?3HOhPaww+KPeggST330)c)||MKTf`@7;AwnoF`@ zU0Al*FqSNKo__b!XQkw$6`|6fcBGPp*)I;nzj#-1GubEDanU$ZyK(&|v26w9=I}4C zUNt&{T>10IM?@tB$|Z7VT;lvQ-LIKNcL z-oJz$A1WZ@O9^#<#o|XynN5?0xe~UOA%J)3myM z;k#!QgZVAu1yfVV?7Ytp*zf#S(P-MGD@?tBlr}%OS2XTkMZ=7jEa^)0-~Cl@Py1yg zS#z>}Q(EVDikErYj63RuBeoD*LZmyM*Z;#XikLt8c=nj|QRFh` zoc!|k9P<6_HLtgi$tQf_sK8Oh2y)xQi@ax-*j8R7CA;gZx*NxxQ=EA6uJ!WE#*rsJ920Gk z6p-3?Z$0r|{b;hdWaXSP+;4yW=u2mp_f}q7t8a=(I4->Z(ei^ z`G1->{|D3N{|CPa^K3rupneC}3QKRVmlY0fPqrt=n%VK}n#@zlUhX=kB(yPei9S=f zT%ap1=21mD>Y? zkA#`U`zB?jO%{yjX0!UFysO{h+)%hI#rIX;LAaV?@$1?pR>NwE z|KqZ|`>Xhh^+yJifrG{7%Rk({s8uLTd*1A51l%M5Ttz^s*J_b@_{ zFb!8tg3=jYS*anlut+8zd5hO1oGRqAzZoezkbFGfOfuw5;9A^YX0z)z_UqCt-qejc z@#@l(JF=yR?&sGs&t~(dzE4aq_+o8x<(3UQS6A)Gocmd9{6RLSR#Owq>q&bvd9F0m zEQ|4i6g}~>Lp|!rWE3mwCHwXfF7pz1ai&Vndxy9iSi?B!NJdaxza*0r`Wf%^0nvUT zfABCfwC{x@i=t~MKeuZ4j1LwmuUoz3SpAZ_m#*`tytjIA&%0V)x-#qVh~ACK?7|9b z&&UEz@|3VhexUR~_HpS9(RNesfvKD7_eI9=?vZnf2O75OlsRU-C)isvNz{AyoT06_ ziu%v(A>C2WOdrASl?r=anJdbeP9EROi9~0MbYf?2THom6eQWz0DzcrHJt?>DDqM2J z#{_OUC>{E2WxKGu*Al%?G|ns-TE`0v%h?0jlh&dN56A|#vd1|ldgD?7FL`d)Q- zpme)=&k*Z3^y)LScL-I__420t?c`afOrq|=6*ZG0VFt2#Wemw;++KP~`>Ikv(tLQQ zb}chhu&W@O8Jfkhd@jqL%2WwtQwn6v`=Z`)xtWYND}xBt%y`{stE{}77ieS5KL&B7MpJ7j{ne4;kHCt?~WFB8WsFJs8I!qkhNQ<*Fqoz)*};9!Zc$r!C)rZy3SMckKq=x)M4Sn)L>1%S` zeMcs#$2cw1gfj;`>fN(Q;98ZDUp=BA66jr;`B%|mjwyIikewzl_gtvjCe7U=9*||` zZ!BcnHAAUA*_UuHiwNN}m*)!m(nO@!A{@~tWR8~eeCl?hC8Ct$j5wLfO#Y;Axsc19 zEHhiu7K+5B*{V{dhwtmznz>Q7mY3_ZWgCU9j4rRAA+A)0y?sb1+Ncucvt`@}ovQTN zq_FJJBsQD-Oqn^cl$Dy*SqIGgs>4j`Cj9{+PkJ~}TB?kZ^oxYjemQ;{EtE+HdRXFN zOBr64mYu;IXHuC#o*_GAWCepKqLT?3z)w4Dp-}0Ww1MGUA1_Hgmbc>Ihn%Q&f9b%O z5f8KGZ9mzP+pH>SV=`Z`Ud5N3-0?j-Q*f$pMXot4d6eC}zeu?3hL^<>zNC*YR3AKq zhhfevIiLN$P|(jA>qiT7Gl$s8c{1iI-Z5v_UR|Lqm9u6Q_$RBG^ge0j2EnR9gDCwJ zb7*aWxE?>5iuwgad0U=nM`4ppcg2{SDWxkFm z!)oEn&yOd$tj(DzS3f)5eu5jpri_fGSazjiaF8ie-n7n69sHN}fV=M^BJ1%o&kT_Q zU9M0yl4C?7@pimLRF=XQ^D-i}h~&&cR+75hBQ3yVfnFiMb4bK2)3buS?aboRo`UBE zImH5VpQ8SCWA@$Zf!;CMCxq;*Bm9(s?C}?7vugMKvIA+)^HU3STfQC`)2rH0BUsK) zeCF`e<9Rfr^*k#Zair%ZW;Iu6HkYdWM6g1jt!L}|m4fn=y=kkrZ{H(x^gN(m-ZyHs zbZD!gKJtJxOL%s-EK9d0BFxGi%^DfEpn(yqm>m<2a?H4q8eVi_G)L5|c$`MKb6{;j zKrPMgw*o+TLt@BCY9~sg(6SHNgv^4D*HJPsy3i&zpJ;H&~7|B&H$YvzBu}+B~w_pypdVrDT z_N!70b}yHa4a-;^mnq0yl_v1?i0|1x6rQRMi=Go)#JpRTdZBRY8gFv+f$RJC+?Kj$ zw#Zm7n~h(P`1dg~GD(EtHZqd-TuBz;4ylu@Y@k=1r5DNz988wIu#{B^ zS<~n=mi+VhknE!qYaVSBcqCoOFjj`O2xS;MMX7!~L`&Ti)~SaInERQ61G33k`?!98 zE|)S*m|1Xm(j#+Nk?2l&TG6eXG$Jg_7Hyw;Xm7u4pGc^>l;8J?tZdgp?)aG5de)MSr{eWp*;GnzceD-Vces)BWy<~~>Mz{FyMJk_kq8kxDC z$y2`0t`PNaV09M>81-mY^8N1Atd}O;!H810hkd!i`A$~w@R(GSOfJh1YMF_IZSad$cx=Q%uD>H}vk={m<<|ctAfpFp?pR^x)^mMEjY{TvelsA4fL6 zRw`qBmQ9TQpsYq9WK@j1KdVnk2*Z{nOVvammuIdL31y{aOuDozlfAbT;|jAY$ShE? za%PoOq~?vv(TtL;k;vuKRk^uCUYT&nJ)qn^z=d})ewk}X$P77G3Pd^~Gegdl%Xx;c z7jb@0h||Ips8A~sR#bX?d|FXSPjOZ1LblzsCAmYsH6j*Hn%$Ve{Pl!t&c!08zArs} zZaqsfid2(X^5K~6!b8GovR!*q_>I!(eOd2?*#fR_?SMf3wn(HB%@(I^ml1A?iZGKk z#Z1v8Ua$*~NTuVG77v~bZZLR950T8=148*gPfuz6(6jc;Pv;z}73f!#J}%CB*d|l^ z`;L84mBktT_vO8i#+=}o<+4nUq@sqb%yOxjFb-bO&zCdIGP^97J2ZvmGub+ka5USe z+b7h?SPQdb1CuUd$l3_%ysucWBLf#|rMc^*OlgX$)VM26Rxb?8myH}!J)`2p!i_}( zqPau%TtP`T!$mIO%A9gR*2W&Lr{BZy-s?(}Hw5SN)hZXmO&4&|1IpsPX17e2 zyH8e>UHa*oeM#T5cbv=pUK9_`vOS)y?#w9s`nYKgn~^?I@Z3MQht$8Mlj7US=Dx%G z*X)`;Nmn{MV}4KO;ujCyUUsPG;Ki~rxC-f0$pkXSjLW^=gG|zlAAHI)`xsdd5oD&7 z-q9HtSUh_5ru*;Net~4m{io-o{X zVqTb;)m~UKu>Ib7%twPshUA(zG6JzEOU9M*GSOs4otsN!DqL?Q8E!HsQp@(yN4h(h z6p|^F39>R7nT{#I57hev1UEB>V!djTU6DOHb=7nOBTOq{dL*+*e%fgv%S&C81||j7 zeebgeW)|;GJIvUE>pAI3d!uUel-z*~)j3wnm{%SZbthG20`cn>;uP#Ur5j1MVD!`hL`B zSB$@7bNu>}9UYb%60gR-d;Mv9>6tIH$37Li*EjNu&sOe8d;7-B-6mt{qbaQeRAa1GG2ItM@NYGh>lP;xDYpELwkk)RKEhXN+6KaCN{ zc}~cZ0bU>|eP+8ozWBPqOKXb`RO&CPcAl8HK==IK$~XQoN7ArHu&7qDdKVFS_)W|K zjfg0jW|l9|Z%P*Qr;ZTL=@)T_wM%XSyh66^cctdQx|=DPm@{Wd7JBlcn}anfYl>rtIdF z%uLCUV5K>gD?BwTOTgVA$QUKWZ^)&kqV&TNZac3ooZ2sx24$oB$o?(zk#dMcQNWW(4LjYDySguA(+Ht1u72X*Ft9}kJbfa+6Jep#tRijw^pz5JC(LDghR19b-Yts zt7;Y7IgOP@irM*=bF=Ff0H3?OlHe69|#cC_!7RXN{ zwO(j;EZPQ#BW8A6Aj)*Ls$-os(egGA4&JG2@;3xx?JWi@(OTXXuFT3`Pn;nh*qB(MP$XHSBP(pbLc}Y!|&Sk4;Gqp8|y6Y4m}P@6|9OoE2=t; zP^@*?y$$wOlOxfALo$~;ZTj{~ueJ-@aJ#kPhDdd_0p~ERudawWb!xv0i&u2`s)JRA zfDL$NwzTg-xORCTK% z5;1hQ>#&V(lhqw2wbn$U4Zdh=TmunIxgp^&ch*$7#HYBb zig1UwyeSkZx=e1bXs$6TBb{wA2x7`Rs!Ubwx)v*rNMoywsPztg2pXtLW!&8zaL09$ zpYppMQD1f36ots9%H(V=H#i+$D9`NW&WI<}q&A8(^1G^)O-&Y8TghjfSs(4NtLn;Y zGXN{DjUn|oRIAV%TkEQ}yRA@(HN?;=Z8kl`bMjcN zGu++mtLf+zTrF>g+|wTPMQwg;*{{=e$JOS}7Rd0L>dc|4n%YV|wqVpHDkBcJu>;N1 z=`>sFR1s}wC63Npm2iYA{1FQjP3`)qJyG7;sk(Iu*A=fd>0&h+n?Dw>pG!8XH zq-F93b)DC*tf+2;mQ3zws)81`DbPZPv@Tb*t|eUU=zVaRD!cAIM<(QR;I;gH_f6|IP4!ybq; z%k@S}tgUu=D;H_ARe5ZNZX;x*q0XARt~R4rQXm{z*1uHDw34K+Q z(_NJ0EFMEsbEGK(g*#_}elOJGuCZf~ZPdDKrdpk+a`}&(A>h$BwA#Xr=;M}lL!&hj zs|?`m2O^>Bkhjj|)EFT#Y(gJzsk1<@ZE`y?JNYUbq5t#R9F9(9c~!6|$OZj+%nZs3 zGx~K!$k<|RD~HPS5Legc_s2}`Kn)#F9F~TJI}vR;eS-67LOx%0*k+>Y`?%jy4fS=k zTJRMY45;dw)E#jRbkgm0{%`=*Ve;*f2Mq~txxKp8=SQ2UNW?V_E$xlfzKLAa9uEXn zMolZaQCE2o1A3^V3e_HK>ZlGlTy1sOyY9C3aJA3jQX}5(EDyvD)oohw3C`{b`m}zd zO^LXn!P?#&b@<{aAC38(=H_m-3pLjehf2?_3t5&+Icrm!(bTN9ccGzGYw+t%!Wy@i z3!v6+^w?aXW~&biIL)02y~<`bFJCP;>2zLwG-CIfu-9#Bk2~08(^}A=oE@IZN<)Lv zfqFOCwE>$?9ctI~apk_6gg0)F*fr0~yCFGjD>vI)8_^n@joyy(7O$m3lg)+Otg(>B z>eOMEjCzON8mbBEp<4BO)NxC{!QsBay^>7B85YuFlT7Vpki>Dz4G%~qHG9Mq{nU!A|!-kjCX%IgyDsy2+@J{Ppo zfkZT>?QV3dG(++Vj9!VBNO#zbDNC>Jh?LhgL@G~vxW zC0f!LylYky(N>znL3ic<4}KBDZy-52Z{jV@eeLl7QNb&wfxs@Ay-j0B!^Hd6rkfCQSM++hXe7DTBe zfa-R>Ml1`y5dJ=J3R|KYA-cvZ!PQ_s^f((~_kth6C~y-v4aTJtDDGHhE=Yi}*bAzt zq_T)!K>bI+BIHp?feGuUvWL&G-6-NS;Zv34<*?gf??P-2p!zC$baWdMKqV(s!f^!p zq#s~MpzQ4^qlf=Fd@8M=+AFFvn}=Rj7npAkfvHJsls#+<@3ODDxOB zmE4pee-q+vNI&S?VD#_F^XC8frkCJIDKz(1l2W5wzOV9Xx)0lmfy6^cC~Taj-A`7 zz`4Y(qHj@W=j=uQJoMXJ)M=t-`lj6*k`a*8P{rgWRE#wdHFW{V2i-W^HNVxU3u)RC zji|+Wh-HJZ;2(gh?5Ij{7xJ?J?IBeAoB_KTXJ9SzsCcUi(AIknaVid?E#nk80m6Wa zl0E>H(Aho*`xchH1La%6uSnMe8KC0?ZSk~Mr=!=dfsLaqZQW16E=PIVnr;K*z+vnQ zZ6&l<79fv`iza~=!54rEh)jSApJ?m63jB-~`YbH%)h7XM{UX+|;?l%*Fy zFUF!X(+)^ATr4vHp2j8)AWd~}d0;&F2v`9fY%WHbJFv`7__Ud905600P{rx6#UK-S zk)Dq-Kf-?$eMOf3{g)FZ^MCl7fGiu|6Zk{GoK3$d z!2Ihc1ypnJQGtwq`Ko}jc7b!!*b;nN!1aeP(np3rE?}asUoL42i{{V|3@mbVD9*o1 zec7-G>(b`mRanbcO&tEv!2E0T1{@nohd(wTxqk54t^C?NyiC1m_>%+Zi1?Vm{yz&M zS(zEkDxC6CxA?#Ar-U?WHcIeb*0Iz~fG$blhEdvEe??kze;S=Bn48f`3Veguci+^3pu?F1(C)Ve@d$hwplzH^ ziUP13eL#cQeE4+#s2=lv(12s36Iuf705>8Q!#Wyae*$rAKLc?(SzQb0kU;lqF5>%; z{t*66DE}@jon&d9e1iB{`1CUpVc0Xk3+S-q2MW{)Ju7sW+Y0UkR&Xie&%zF*=Pso3 z2>QqWX8+5+_+NI#%s;Ck|0IU|T??ro+{{Hv)l_su?WKSIyvIDMhBTd@7RO}bCFs%h zZiQ!QgYn;d<|B;ne0YXG^f7H5G)2w5t;_m`CCXPgSE+yh;K!vKubanHMN#$Ogp92h zn}Rp2zR_!eQaMP!{&Bu99)%wAj}LoHPtJHqGF%HAH6y6b@%A|*Ay+JU2a-d6(p|X_ zMMrA2j{bbTq${`It}aDaTemir1%X1)iM4&8$w0?sW=pHe9j8V z0Grn{LBK&&`<=K<$_yM&p*!=+=h@f@-uZxr^4+oc2}sJ@XASEd*JV?&8>gqbLi0O$ z*e>6>9@4yI$uS{aJ(P`~P_-WPauC>2(OTo35*;hJo{9j;tm!yL#xO=az(Vp_ zEJA%jQYcQcr2IC?aZ%rE;(j){pn{4u`4>t!w*3K-0OGqZw_wR+?q@}QmRODzb+P>8 zk79qx)xSU+$lqE+b9X-q)gsCGi&~b^A&A(J4!0EHuush80EUcg5w&PTh@t32OK^s>6&Jvrt>4(kK`q zWs+095GV3)UMSItjtfwTe{qg@XjnFiW+UF1C!Ph}WKlmA2tw=m{uNMclABoCz%r&_ zpCKWn3dI|H>Hm;cDx^u7dIHtvI{Sv(=hP1IDcpJ-fn1bqUraT(iaSw5$(ui-qR0h| z2K%KOD0-Mp?mR9gEaa9&sKt+7q{l7(tO!dfY-pm;kQQO5KP^WtbFmoJ*O(%%ha9{N z2cP`mU3&1RQfXV(rb+5Wlf<+hp1n|f50vc|%_%9l_B|GD2a45UMek86x8x7iV-@_- zRPi87rmN`IyR+#rEx~W=BHyqZS*jsyvh@d?B&yp*Rb4-`#3@?zI5w61+emSEAw;B9 z?!_dZnmC@?|K`9p@_+NmqD*;_^!!JQ3Z`F}`^RUCa{n0JCeSZ06rI1~R~a;w3VO|d z(VSt))_nCihq$J83cg`9bMcZ17~(FXpE1f99^kYW1m@F^FpOz!oASHh`?rr7&A29q z?=cO3&uG@l{^hv#&sy8N&Tw9}_1i~{rro&hn)9DEqF4KAIlKR?nLP9OER5?raroJ= zJHR{e1+WUR7QP5p3UH6jR3rTgpc0w6Kn6Mil^(@`2|fqr!~X!>08Svi4@`qU3+Ukw zz#f1#!mfwi1k8ZS`z}Rn1K0tmL`wnt7;G`1(y8~r5yZT(Ukm!ryas;{_&d^6wxxm{ z2TO0}s0`_2*i*1n=5s4}8U9FEDz7YsJqUXS_EBJfPuKSyXh%8>`w8sxut#C7U=O$o z=~duEpaTyfhF24qBe1kBwSo~K3s5QM#o$YD7|f1Qdf00K zm9Dh_JN!>TJ$ygFTLQz<&JI|54gMiwHdwkpe}#V$Y&M{BvnyebgE!%S47(1bf}4>3 zD=d|%ZG-(0?1C=_MsN$#zk()kG1!UNA=v+BH$2}9Gyd(D!$uY7|DV1cR`_o|9+pC{ z@TK+cbm%1^mz4PfC9`S2bK?s|1?>?gIyKgT?}&AFP5R?YVjkM{lq*&`pqHFiyk;)_ zo>*<>z=pPsSI)X_(|`|`7od9nleGlSgu=7AI%FRIhdDw%9)1hDxb{Fy|L#+Vt>h6w{6L zO8A!{?&s(QQv@im|Y{^cMWNKxiya4Y-=Ks|igCaeaQ4k24$UqxOc{C3!5$eV-Mx3G;!<1MSEgtQr);r)o($ggpZrgw-NOhZ8!SZG~?FQsmL0 z-VeIK0i-8mT?*JMz}MhTl>ZqVfnN_+fU$s12XqR#4?b0Z#^K)tKZ^MORagGEJsIBs zGLvfU^s)aq~UHH|kUV{6WCw8&$u5 zE9J(DKfVb#7SGHem0WcGtAKs z-RYQx?`2&5@$yT;rfib6f=f6DShD-x$&Q>?v&aGCfklFQe^Ri{*N$$VP)M?TS1+G< z(>XRnGlfakuySQ(Gs9$v}5y(e3-bQY^aSXIp;_OW1vX zNy%RHiDI|Q`uaY_8;YZH>1~HEJE>@8zDYaqQ5K1ha?Lo=n@_ZV+5f{8HQD5Yue)#i z>clt_{k3=6ZBxdP-M_R*m#q6n!LMI;UFz(7QufYM-3P{I6U&!(l`c6TBM&9MY}Z{f ziM*VB@qW$xEOPPDtEEd@#*@g->%;fwUO=wOn)c8Q&e5cH+rY?qE528ZS(^IcGY!8e zWabMe_@<2@Qy%~PR8JJjy60*>m#4|d#Ah;|pYY6SMZSVuaP89*$&Qx(?AE6ylG&lX z#z$g#HSJ+OIu34>?EX+V!?2=AZn-u3L;iT7hQKv zv0_2gs_3GtZgo*vFS~f#)m`2FzfZx+y5D{N``!QV_xyj)^ZWAf?&q8{XC{-GbIv50 z^P2bfcYhuqEv~uanKf^34i|6u_3ej05F*81=iAc7yl+!h#1B6inmSi}HFoE$t8SVr zCf2_vXGXtB`SatRGzn(Gru2LK?Y5eGSGMT7c z_D0UEYiEg7zf7f*^2~3u8F87VP%^PKK$cT$+N|` z=G1n~ihzD*M?Y2HWS6uQ?&@4|= zq<+w{OewOC_Vy!npR zT5+uy@z~#$U#2WOAUhJf>Xnp&!RM0;9*Ge3PmPq+mTSa6N56kV>7Qc6rL(M09BiH| zs#kCM?UJNvqABR=1xLT0C3+2qPYu8NV#>L3t^D+{Xz|HE&?S$a4H2`0-@4`C?Fvyf z7yCFS&01 zmzT{SAO83M^Rsh*LdIESJNeigpD-^ zc7?KGC{neA&v?aVes$C$_xW`@jE?nOi(;|nh{LR?^VYc(_uvjj#JHK>uB{44qD+}i zJ|NS;>9jJnwAJqsG|m`PpPHA{iAjNqinRA_rv&+kW@hvZK|O=rWTOr|+CLa3jfb{i zF1)BC>Ci;o zxvY|wwh`k3L&DWwf7n38GEW)1OT{X$QHuJ8MFqX~^+%)_vp9B}^wXKlsun@6AE8EV zcBkAVaw@aCk7C_zhG^!kqW}eDBN4nPj)v_84t5 zQtz3JY^L0{i>i#U@kiu5pUH(YjD42mjxZ_*Rwyy`JLI#d!|nhpgSR&$mhrvo)SS6l z>q#$WLq#X+2iwpqN$MEPp3mnU$>3S3(Q?WG;>1bM<|MkOjuo|f9VJECxIqbD-eQL^ zNnsHmO-G6{>FsiS0y29SoDw!bb2|u&ma&DAk$E?f?!+qS>IZT-6M$`5J zl^>+!o=R#gnSIcl-rvnjgUZm^oSh1$Juc3+EnIqD5~QtK3wK;6nd=j%Jbr^$@IPnJ z%AD%h1Ec&x%L0*SG)|WUIGt8w+cp!|ixQe5x>H)gcCD|$3 znJl>li@gtzzOoxM6zj;8h6PrnwH$900{{q|p|UkD59hLkQ}|Bl6Lb{sbBkUr<#RCf z15SAkzprJUrJh|)wN=kvGJUr)zWjAtg#Es^BGzUe;N3e!Au3G*66&y1gWf`|NQKBl zq!3z}q~je?ipnl|Q}zkD<_%?9$tKyXfNI~}_J=j$VXfz~*Qk2`iHuY54k1qD3@k4l zX%{Jjr4P^nE9zKUsZgK_)9 z)k85_O4Qc4DS^@QGB4+1ltM7YP|BcvX~8(A)CWuhtDGi=OY8X!=83%`y@|JuZPaLJ zUYh)I1ydn#ELf~!n9NJGNKv3{r~vxtzMeP@FU)wK_RK2bCgPd$i38fG%<<=#>Gs{e zK*sDiZKb#~F&<+@B~5M9GI-cF^Q^eZ<{4%Al_GzEQY1R*gjtk`8GM|L?-ymDmWHTJ zGSS}Rpmy#2h3+%JqGiJWAjWW znwb5@-fEh67Frua~u-^Si7@{MPElQ&!x0_Y2;(oXS?ya%X^ zR#CV&GpHm4OtG}X!E&mTOhAZ?dX6hn@!?#EEI*Y|@==<9Y7dmfkeDp@`1`an)gqSK z!74q2AlO(=rUp5yrH5>)RJl*J)u$N6Ww;*GXhM9F)*on+N`vj|1ZtL1#=l9knNFLu zH)vZnz2#Z2XjG~9h>6Giy8d=0TcE_Rl*X(0r|?BnO%-hGi(y>qaW8w(C&}${xkX|{ zJOx~myp#{)1AbaqNznq8FVNH`f!a8xKxTp0+AEv}Lsg%a;RH~S)6Vuhg zqX!swk|&#Ou&;@bSC;Fd&pNmp8H@j5{v2I|MaA#Q!C9HceNFi&e_4(zBEpr(nI05{ zSxfOdNx=9k+eDX|R&lgM8$C2@r_>y)i{Uh&%k7kC78F!Ol7UwGg(}Xvg&&xp+K=h^ zRxQ;tZm41U67nl*LPL^O)3vOR4ly?!iRrnBjcMDVklv zAI7h*jy*2pK8?8VDu%TK-in}#cd|NZi-*^+de9BNlWsM=G0o%+=~bL|IR$@@NXdOO zLC!veUs7#R+*Lw+y@`tAr!%thLih05algZu*1Od5p!fy9pV#akKVg#R`FQ6wkA51N zq~z~0(AzRb_0d4GW&U~R2DJ$R>i9+S@QN}@47fM@r`L-S1I0W-51?x{gt(*!Im(ul zEe~P@ZmB}S^)p-@b-{j?6IicD<`r)Ur{W~DY=;=nnL;9H&Q?TOAD}!P%P4KdRg6!d z4<_8y%R4E)Rz=7Kz|Dlv65(rc*b8TgQzyC6|U zt?M=Lhop4FsJJW6sGtSCit|)a_2YBd1~zvY^$o4_*keQJQ}l#t9IP9qSV;@za({#+ zNYIPn6H4lp7*5nGxscZ=Bez~E!P8dgp3MIv2erf|tpA+N(Q6??- z9_zSz?juXhb>~C-K4t?wL9aN1v@2(eI=87+_vB3DmYAQmhrIVZj=Gs_p`P-cTv?*0 zN2kT!>pD^s3(euwnc$vGZ<^vUxs&m5ypMHI9-j$)L+%slK3ZheX3oF_P#nFW2Y0vx z1I3TP#z9N5bHx~gfI9}$s6vhj<7oal>V)2wmn?tQ;B9fpqP#H+Go!Rey|_<%?#7$= zV7sL4A1dZN5nR~*ks&oF4_NQ5P%^gRDjm1$Az>P&^HJG-l*%Iru@P>|LaLQwL*xO| zXfbu4c!6TPCfX-SCWT^zRd8xPk&2c4QlLjmB}=%3IPBa__3 z9SK)e3w`UIobf~r+v3~yGV^4BFTkxR%)4%dFR(XzgRJ82$adDkslJzFgXn%%`6U|bg2Jc`+?kop~3 z!*G&AE$@A#LR_?AgZ(yl@YmGqw!PGg8q4s4VXl97u9)}^_SkfirdmKSjLSmcKr5sK&P#y`GuZWLGa(8G3x$>*={kJGsM!x`h6cN-os3Pn_@LDkjRMO*9iNp9`0W zna6mOC1m6_VMr2+39(d@xcmazsUJz1udZ z92;rFh)x;D2?=$>5#uqs^|LooXQa^c0lv3=zG0hLt?tdvzDDghM)e!rc7OU$+k@I^ z^jf6k7{O8~uspR(HO4cx^-@wE=XO0H@2B_*+B6^YW@yhoPEbw@pv)K1d9?<{%;>6E zDrTWcubLbGI$bzo&Y^OC7Z9;^>AaYu^a(LTzCH2I0FQZnJvXbz$k0g_=E_Y&u?vUI zP8&OBpcBi&W`s%8yub6FcuIdU_{~G*@11gWjmW;~1GfI-28qT+5SS2JFnWW4uZp^yf z$W(B>&NiFHXZ;M171eOuAh&xkPEe#yc#jKFueb~73ns&8`Cd^t=b&DZon_VQRRJdB zNQ>5-MGp+ATy-peV#tmA5?X_BXtA5c*IGK8U1)Sj%|40?^=iMeZ7r}K$PUh`s|eV# zy(p4r7QCx?)U|$5{Ghn>^e`iwH+I}TAhU(Y_EhRu*q8&qM)~#w%X_(r*LwSnK>7r76y+w zL+)l2KI_bg(262PExl(5u^3)FC_4NB5uR^smgvM`n=gzW_uVx~{M()0Rqz z$Xb{yt+nQ~wZOJf*P3T8sU0Eq2{hK8!*ty z)^e<#UYnNDVef3uz$$Co)@5{B@>iK`c#)#?>ax_VvL+MIM_oxpeR0K_+O`a071@&8 zU7y|9y{4rd>zgYIZ4E^Q1$MBEP0ck0-Cb3nO0oXBw$*FuySq9n2oPGF(NUfSWTzCs zQre1D4FwtLwH-*8nj2agfM9pR(Xp$b(pFG!Do9@oBy2^_^2Y4K%#2pOU8pQcYc4RC zR&|3L%WbVnt65&(keUv%rJ}yOrYbc(yB^Ms<=tKF*6fzrPC`YlsaV&PW6kO)Zp3S6 z*5_22*A$iDpa|{V#ht4gGdf!2PY8{vxz_Z|($>}1PF+)DWovDrwIRI}bXjS3yS*jD zTxF@o+O=7wZKmd&;aN<-HGq%uSC_VS+w3s&#Ddb*_EuQn ztz8A&s->y1J}B`d0O0g@G`cjx34bz6xeWM)fU zW?o%hx(%3&Ij_5{E`7xcE6BV26(zalHN_2`XcsH&x#_7b-Ce0*3_CKjQnOcdt}@C0 zpzH3=X_ye^fU~p~cBD15rDt1Xg#5OSw%m-2f{NFt@L^Sn^tWZWAo2 z)zz6Drqp#{2XoWyFkm!y6jlOTv!!;GH?7GpO|9M{lvPx=*V*lD?bg%6I!kwZYC}cm za{b3TOIbl$YL2t*8Rg0=uJp+jrhIa78vcb%ZbkGDHo3iyD@}4?$NGawZuQ=O$0HZy>JJ{d)(?d# zi`<^SME~HBo1qb={Bf(RezeE68n3j+Efl8gana1D+;NetaGRN&TME_`wD@8x>zu;~d z8+K6>_6FuWTG7|$b5z>%ep9%%xN5KKn}{R(;)+a%b)RhLl#7vf*BK0zc1Vxhx2 z&mcdBG_h++N7@My^x!ngPNM7vq+LkILD!-FV~7u;+=lW`kS3^61=0jO$%8JU&SvDp zp?ZiAPdiXfAeE0H8`O{e>P0!hIS5nu3&gJwTTpfZ>2`>q5^vxbyn`}=HV|OtZfv^~ z(GL9uO2l$EmPbHuK?L8p5#{7~5^!c7md``k0MfBgBG%U-5|pD1BHU*dmJ>wdb?5~s z21>v(;tfZR!F>?HMnX~7g!l!@pGTbrXa(}mK||Q?1R^XKteP?FE_TE`JTMZH5{Bv;YsocMS9<@g`-!& z+t=l-c5qp_KRUn8`PnTUk~0=lHXHLU`M=(9*;q1tThkBoy;3i6lJs?}FjKP#m}CRK zE5cdwB6{bK&XWC@L&qK=UUiQUJn|q8U@SBb2a3SD;fNOypG720(RLifuMqD?8R2Ty zLT;21X6XrtoG3zF84<67NGCmulW;v^KSUbNFOUyF-FKn0$P+BE32`&@J8UZk(RBGW z$w{w5SsLo@fr#hX*U&kXlg>8_%Lu1LIzDMU1dJt(ocPaWBYhC*@1f(U_Ym@gGYZ2p z!V-}N$Dq6s-`lPu}JG zs7r<`GK@&jCLF;;L}I&`k2*&o3-a$k@z7!{&p=rzM7r_~5V3}Q5&3F}oZ($4BPO$? zD_sMTqmhaHjYtn6or^SCpU_l65TUb_$g3dIwaM^E>dnV%ku&pi=w8T&vI|fW>XV`I zCTJegq&t)0q6c~t`Xh85=qxgPc22$PBBkICk>hGrR+c1N(TN&Pp17>*31jLZ@ns!fRk$@C1<#6edP^+ zwi2)1=#l=(r8&7}&pqLnCS@k9ojLG8UD{yFnYW9JL)dYF5OPCAT5#fCDQ7?z&7m2lR=fVAW|m+F%@N`e*{9iQ70R5 zA<9X=`5Dq=U(-?kE95Jo1JE-lBNtQBs&_*8wSJ0gB=6+_mXSV7_{BF+|3PRDMEJyR zls}I07m)rKB7L8<{(TT>%^dV=ydQFr8$p_QWAaF=kd{LVycQW>HlzF|lwS)m5ZQ0i zDs9j*96K^5L_?${E1>DnpHaRQWs4!wgJz>_7Pdp)XE{X1vM}gQynYgv%b<@SvhSn^ zkPFspkbv#Hk4P@C4B}!a0IGmkC5YEqHxL;IYw;Rn1R%_58uG70 zq{ojzfB@`Xo&h zq`owfi=9N4!xt}_;)%qoz!Obf7U$&TCNH(qP`~O| z6AW_aAvpqIK;k7Sy+Ei*>gusz1ehUC+9Se>Pf$_cr(*hr3h)_C7OlTPEG4NUz~xj@ zY&{Dv$JiJ-h&T4HL4;oX?jD7aUGy`MWHWzG&w%^l#8J7A6aO$@j{EfknJ^Q4XF$m0rX&Hi0!SuKxnzI`|t$I8^6_yob;Pfg0{VRgWf>c zNcvhZd@#*yVVPNSffK)w;7BQg+oQFU28cZSG(iZZ+q!@gN+v$0f7ZQ2_S*IV19OD!+ARh&-9ySwRkCy#NPKVmCm-w&b^9*TO26Pw(6?2xYsj2(<6 zu~+j7fF+6#yLeh8JY-xlp+s>;z%Cj%@v+y*{>Oylgm}C3*vt3M(^uh5?-I!_z=ZLl z3ix3l9Z5_bLGlr15_{5iliYvFArqPTiP$X|Vk$}qHi%3zf)r+K(U}q zKYR%%9AF~q1~q~km6mj?3HSNj@;D=`}1*}H?a)ZEhl|?P|r=eV0t<6_F8NPrjIapyyI^;spn~MvX?zc@81H0 z%{Q0h58O#{EI^lHy-j~sba9KWilqIV|AiS~UcbFI!vGRfkMlrmRS8sz{r>AO323=l zgXAPH3PVe@&_71&$2jrhw-f?w$;1o8))YH=wmL&(KRKnhbJC$hXh8sEGsf7@ zw<@^gug_0*epp))674_!z@eT!=SNh0R{W&_P<8p44Ro@8$EP3y$)V z1tsym%OKu&^Q(wy?qvjIe*5INg5>U7r%yht!5Kz4uJ-FUw7`7w2S^+vT`c%5;9Cuo z-wB$0`)23|9>70tH|B0&{L5z`Rghsu#Yi|B_=K}zJ0Ahsj+Al?rxgS5!M3T=lluaCMFGC`Pi zILSaAM4cNTf>S;L{T^D3Wz!Lf1KeE1B8Xsl^N@cYaXplU^xcSi5RXG2K;h8mC?iNH zxu1O=aUJ3;#A^^=L!6ITk4S#Ixdo9hO?N>nkzRvHn6e6_3Dic|qao-#(gb3okdA>~ zMe-HISj1z{1kwaAtw4GmB0)H7kbWKdGxP=W^@!&XbC}e@u&~LB|AF!xFq~C$wgA$Q1fWn~1pzBaphZuD>7;(o}5^diJY=t0N>{T6xy1>Z6_CR3}{!=sXhaj~pkY0-T-}o4U8~ay6SfN*R!pnW|Cyz~u3;l=r0`(l`ByJ6 zq)ivdF0qJ&u_MQ=9Fd%d^(bEg$)SAY39q&X%YTnFIhL_r$Cw)5& z@gd|_BK;ELD&+r_1E>#mIIN$Jdfy^_9Cgn_2}u9c{c{X;9ztEhH0|dnU!$xZ_0}Q|LLQ_SVflaJ>^TkVZo#@`h-9385!>Gik#QypWn_GK5^2Jd z{*#;MQ3@%} zM9T-KQogW0x8uHV1o4eSHB{m`Ui{m|*^7*oYVqXbk8M9x93u*G8;_rf|0E@{b!Nas zi6H)_f$u%LOd0ScSrt70cU&LQl-PZkb%F)^P_F7%jMgHk##oF4@ zl&}918pw3a60Lhbf48@Gu6SG0|uCzDK2x7!%ryl%OC@Wt5b7O4i-$TTldw%xm>$j^#PrI&Up7HIJy?4Im z`+Db_DbfiUGnT6rH4XBvzUyMd1IJQjt*Nuc7oMtL@^zP5oF7jGbZWj&xq0lKz*Sk_ zrdY>j4bJ{RBYs`mpNMB(i>aS|JD%}Yg!sfCm{kW}ohydA8XQ??W5lK&<|}HCOdJS& z_eiluChDYhnc9%IQeNJ8)7bEBF(McFWxTTl&Ys)e+A9pe>F~PSUVkWfmN@TZ`e%J- zXNYmn7wuY95hLz;x4ifhtwOxxDyH?sW1(XHI+$%bV#N5eT^XOIMu^(mZ^^mmv`P%H zgldcX|DMwQ>BEY@9SIPRXTH1imrJ$c2a7+xb;oaKh`Y<@{;k9rEgtmzp;mlP5XHVv z4joOMCEoYx?;2lOg!B9RJu%D2-%q)9*3ECZn&HHE*PZQ0o?%4aaOKj%UwxNy{%uV# z`}FH6DIZQdS`w=fYra0&%O^#O)pw1gKd<{dCFe8usW;irQ*=2`)GjMfi@Mb}i4Pyt zi03zdTw7vb#P9CZ=PjEPBl`MIPs?Zs5qHVY4XplJCVsuL?cmFdAQtZ&x^uU2j);5w ze>wjuC+7bB_emFj7bGfwT_&F!79u9RdDGzYFPu+#?t9mV_pvczd+a-R+*&}1|KrH| zKaQ;bWvC)5V!*O<-ziE?MekPW-0K~qQi$$igm9*g zn`cWkr!8@QW6*P-ndnk$E%kK5u<({LcE@YUUx;gL@@1l3obbsg(@UHnYE}3Ur?#CG zKA^zV(Ypf^98~a_<6@j{EW?);=;@!Ba2WZO6H@0EHI?dqJ6_lotd}NCREtomZsDR` zQG#vjELN@APPH6kg9I^5uf3*7r%ML&^YqjEqfaldYu9(ypL7|5nvS8)Hj&+TeS550$;AVu2wfp|tS5 zI;M#~S^d_srzroPG3ssM4ka73sZZuSOwV0d6e_(P%6ha`mCpK}O9(0skZ~KB<}I#m zl7_S9^$K*3$B|Se`04k;c+UXk?c3nwc2`hKW4)Ei7Rp{RCYH~#Z7Z{}E*Yh-OQCPvL_o&1Ey!NuB3xcDF)BgHv*Zi{S` z9&!u$akvkVNEZyBbZ+{bt#gY3&2vX{j91IOH_lGj;NFy|VwkdW@w+8 zx9kfMX^)cQ_p9+;Ti?fWE;}zNWPZ1FCNGnwVm50dSgmXWzr&$qIIF*0%~{4REOmNJ zJvP#tyhs)sV#_!qFXK)xj4-WFHI^QCE|t}Y-^9=whE-A{cytQpbc=9=l$<^lrJww% zAb@7z7Bp89L_?RB&I}oFIH(;>G$pCoA<8n_OZ)rn=JShvH#s-ONK0&oTP_r>w{1Qc z_Uf?=W8dRG`J0>eBy_OHgk4hpSB+IB>Nx+F*)Lq&9h0nINdF>T2;+NW!y#e{pN7)qzpc8t2sTJW_0^_yM^-(xJ;3PHzFW@(1q`$M&m(yyql5R zn6_=%5;Ku!)<}g0zcm-=u2T~6ome4I`~c603jHb`L&(Nt3H z)zh-0yE)6V>5g*&KBeu^FGs^u`v(q`De{8->V}N9%7nQuoD6@vb(6i!%9rO)f6jhh zwPWUU>cRImCl4wEB5X7JHWbh9)km0mEBJTz^e!<~^e>5_yj%#&`6+y#bh0ADac)ZD zc*<;$bd)Ign2@9ae8rwyqLl|LT{WWMt&>_PCUBUu$9p0rkJ_AFSPb)R0U{7qBUU2?%2W2`cNV74UN@qnd;!kdgwcblL{SgPTB(3t%)l4C72!5cT%J^AxY_si7|zb@u{0# z-e-%wM)5VuA+k<`C6Q{QbPBsCPtv+g`*`VaoGxMyO=q@*w9u|Ni#cAd2;vMI!`xK3 zA%v$=J>kY++!y`H_^?|mBo1o)-wc_YV|ufVGY7`A0hY?4h!`$e=Dkjmd?uO=u`A?) zo)_Itjh$ARJm?@IUnf~)T3VSWIiGjZ4LzKELFHL$@c}2beYWyM3}=k?&4{4pF>bXe z@>Cq=38`2!WfLtf%FJ$~MEscSvQe7RUeLEvm}w9r2cIO`wW)O?ezGeTsmv|=WsK~8 zO7LyfYAE9(H|OTYI7e$L&2MlN0b;m zOLrjg8Vpcw@u}KeJNJ8vv^b)$XBlen#e# zeEQK5lT1}au@h!)Y!N0yX{2~dg@s}{dp;}iJ|*Mv6^h##Bi+KqEG(2B;OiE~9(H(J z>A@tm)-&*xacAHK+i1v8WJQ{HA7`pETE+N)I98$>g$iMYj*4d~zmksh-bpj%F`Bm> zbpA=j@QyF2(2(;+{UN)U7h>;KtAsBGWa~IOR0y%yM17%4$ugUi5jq_kpkWpU0E*Yx zx!!*oAKo-LseRJjY>Xu{&&4dwq{`wgrs-w@cdGTaeutr%O3QQmDpahu;#E_EdV_WY zlRHkE1H}dK6}Pks)`tb9ewL8nU>Y759@#Sbc5kT05?Ybq{bDh%KNjuS#KuowzR^ny zis)C>7kn1MoT-0n8Wk1WqDbEu7Pz5bBij8gZy=+*Q%ei%-UO+biWnYbjp0hKBO=;_ zIah~Wyh^5}Fs4vFF;s<@$rwg==6V7cs&|ab8&bLyY8|yVo8b)nGGX0ZYRoPueeMh{ zEWjVajl|UJ9ARxknKn_&fRn;?%Q2gxPxhS z(+$yw)Wb*Oe>y(UqwC)z7=6FmblXSs8X z*~hG8Pq@ZMT+9S@JeGPT+2f^e7H67lye-kJ@d+DP>WIa)B!bg9Bh+3cSEvsNF!_`m zLvMkRC8f3F@dQzV!>bhrjVfxp*%{z;$~WT&Qa+918G+5$I%rNn>!(X712{^aGtl7I z#>x#ggUN5;?96%_6C1Ls~#dA7Q&%Z?J>}(Ln`@?!zxmimuH2MO(UWpo%#zb0asun3VGa<6Fau+K( zE9Nr*{2YGK%S+LgX^iMI#N(Gy8V0?RN}A=RR8gpYGugdjO(D49r2YNuqT4a0EFULPhX zJ&PsEuVc(ndnD?}30h@4p^6vdD8&)#fZj5rsq6_G``Xc}^WV8Fx~8l!_2!N z-G6FBPj|@sKWz_zFD#@uw`G>(@%3y04Y$NdMXaY}Tin{?Hu1EYQ8RW*Vq~jk?6|r1AtcZHW~as>dZb&^nk+Jnc~nf{{}4nUdDR znbIA$A>Br;P2{!<-UP2bK(d44WIcB;kE(?&&zKGc_?kTDiX`VxVuf z>=ms+v-MH-RIMSwaM-4f4^K|WUDO=0qtfm@!raEStLz(hxV!x3B{Y7=HQq-12F3Lz zO{A~TPgjij(?X3#JL`_1Wez?Icbl|zlJKB}@z5dVif&;-5QI-v_hh8@#w;3=_nk;6 z36I#~ER)aZ8b0g2+PEdN$^X;#(96u<9pW-2j#h;^TIi6>dZxclGqy1{;!SOkBbSak z9{Bm%-Eo^c7ccR3v<%!_^wsA{uf08d@w4HL+uQf+c75vlFuDIo@3wnxtV>?3?Urun z+i)`aFz1}bo!ry!FDh)af8|;E%^B)^82fz8HV>0~oo(7s)vE#9B9HRS{CKI^mdeH1 zB-24^-1(y5KaS6R>5^xyAo!&U$HX5fhUY^-{X6m~>Kn#IsnHnOP!&T>Fw{vGMfdjb zLX>sD++f*sN;&T15B*AYU?&%_=PqjFBXR5t;x*EF)dI@S$if2@`*-wxN_oR0gJjxR z{?qk}39qC+wPbW~oQk~9Wh|B#D%I-goYy-u`9&eMcTRK$?li{UL<76kOh^fp>lcPRbE}p zfqIzRCwffFQgMsoY|OCRI@WexD`Jwvm|tbcpV?0>Nb;~W6OUZ zkS#m?cHjnMgtmIUs-l+{L-Q8IgGHk>WHfP)`t6D~L7`02;CK6T^TLk&$WeY8 zwlV;hp16kZzQD0MBl5eR)CabMD&y-PODs4b! zRYl{9*43SB3ISdUYfK$271r*wCV+L8j@4-;%S$_L#RMWOFKt+{x~eR{0;`xia+c@h zrx$iECmXA7vlTR!mNo(})YI=pm&LrQ88BK?6PPYrMr{S)%8Y`wnZ>5Uvg*}9vFchY^IC0b`7Nkan$}pndTnlb zD{S}L8yaiv`D-fcR=~BZE48$`$=Xp&;L^3`di!!)QFqxIAZWn&%(cZ`<;1MBp{uF3 zF~{6gMKCaX&1&=70*hshzFTMNC~sPwUR%>qfVY@YpIdA)=T_F1q1Gx}YkqfKds;ed zwMvWE71(RnSl@9b$S;3;i@`Utiub`t!t>URAjAd!P{QZQDRO5%vWB7-LNz@ zuPdqO%FG1ED%7-8G&C2RZ8@mb+0>q6v6*XAYf!5wudbxL(pJ=(hgxOLB`Y#48LgS} zSEKFq#qDj?^__}W1#_*nsG-5WY6adyW_4vzS$##>3VoBVGQWIvb=B&Ed=s6fE2zw? zGTBWn**LL+sko~xYi(1t9f(|hX346y?se^LFmtT0t}6nZn^mjt7V>I}YCy%cz$Q`G zon^~yt*%&6UJ8@3+SQc>b!+T!MmP-X)?XuZzdF_Sf<*;tcNN+31$gtYW@DdGajh*)D&dzS|jM;h3ZDnm4+0{TY^V{p% zGRupax~(xQSG86bmaVH>-Ca&POSL6;UD3Mgj1myXLUUIBDr;`n@>;n5*{u0ltGjAS zGxdW)wxzZrv!bS<1zvQ^o6B03!xJwXY@d0prJymt+d}-!8ZtY|v)jwNi}kyJM5VXa zu3M8<0+YoKb8SPFy<<%ajQfN&1z#j9e+SAI5S}dK|XijEsYg5UpnrdsE z&XQi%*3pq)*;)sXbw%dd%F?#Xyh`G>TAyk!ZOU1bRu1!1U2Wl-<@xz#nT18z@g`GV zMn*$+ixswn%?0h5_0?_Z>%ih=tzA`SPA#jm!b`EPvSoE`K~uH~E`w=yYuQ>0p0f-` zz>KVp>bfeLL0-c!FMXBXFEn;n!LJlR64toFN*BHM2P<8x zPe4<-boX=rj)iWYFlC|J(;xkVgKoX; zO8?w=Vah*uf7g%pxmLF@WuLo$K$vpRg>~x{=DBF0KbYrMOTv_QZuQ@Ue_@@Qe1&zc zFlC*q_xvm8++<(lu9@ za?Q2Y30Iir3R9-J&%Gf`dFEQb!g)>m!7^8vvdqOW^e-H9g(=6}4<>{w40DAk!(4ET zSNP>Vaiw3bFy)tvhVyUOSm~zVnRr=pF%iZ(8*DP0M0dpWsTGMr)Y%cPIO?VDvFC$Obk_FiQ^N56d@F4DiYOuTy zkuXxfMofU>pbBjNFNpV`oIrwUh=gyr5s`3Ue?l7U0oVs&((S7R<$pxJ4*CPi^r!;} zbJ`4ck_9~isHzx?@*pi1yRi6bqzPD53K1;gL0}P|Ad+1hK^+<~7@IX9E=7C<>$o9; zo=iYnp_j3nE<_Q_w<8{go`PB+E4DWiu>krzvLiG%3fmYTOh&3%VtGXtn_NwbIxl2xKUVQ`H zWvU`!FxeEw6D~x3#m(yZmvNB3Rj|15Zf(2Ffslz^d$VLW{a|7zX~OoR6Z$XB#FUhm zJK+mBE`N~+bC@TKqtpuBhY|wqbwIbH{SbsQ6cUjqXX7Ir?K8*|wwIuh1O*}>-bH8t zWdsT&ZI84;0s|7Dj^L3J(l$Z-SH8uvj}CES{A_=Nx!+||4RD8pHYYSt`f9}u*qX6+k&(fX@W()iP()Y zG92uNQlSs9O~N9RenF1T4(u=K@++b1p$KRdMEc@}Y2qY8rNVY@$2xYDNk}ImU5_*m zkz+#ECqvpMh`i?!82XVOupM;?Oi6|v^4hgf30|MHV)DMvVmZOA%upjl`t59}5<>@Q zxi)lA(y*f-G92WBsA@(W#%iRq_>m^V$}@Ni>yZzH$RQwf80nl(LPO9B)FDF*IW&H} zJn7u?u#M{wA4R+taX%D@?Q|l}L>cLn{e8_2uP`N2a8bfB)fCkiOjg(D^3&-t2#{u44&|ka+zKpp?!3FeOAeH77)WC5%vA zlTwQgcXMM?*{ZqVuv**V)kXJunfJ5h8SNVj zFM$c@eAg}XZjIg+Gp%O(j%)6i1Qe?GsK~65?ECR^j9(M>D|EoIOC1ouuV131w8>h09^yM;^_YZ z%N0nIvqd`Gdq~$IJ&N=pq&cKh5bs7L9h5X^0cmnQdjoMhVh7^qSVmgXBBbXbZiMcH z$ng6zE+VAW5NMq=Zt`#f(pVpW&OwhtWDp=N>OAB|>v|cHbUrm&gAVa~tW$$}WT2Uk zJlWnXXgBIQ5O2bAav?m8NUoc`NKYWmA#H*7AWys=;}Ne#B*%e}w=sx>D82!8E+8$3 z2ut`Dwo5)gkPaD+eIPt%BhuZdn~Zo7aRbUCktQAT9n`rWc@sn!(&>;J<>XxKKqT*r zLY)#sa{Ye<@g778_0&iQLTj;{K_oquTq?gryd8QMX~JCQLFAacgY^iDIs@qkq35Af z*k3Zp-V2f5{V+s&*s~}ngR~V&$9BlY{0P?9p`HlciaZ$|Tv!&3Scqju5y|H$atSBB z9zK$U=UjH%rH)8d{O3KAkBpib9r2Sc`42sE$cu;KcPrIbu0z8t?k@E>3#7E*% zm~+b_VzdUAn4p^4q)S)A$zHjpeL+WO1TKORxCm0W2ct{2I$et|**$rBt2foaWL zqPR=t-Ma1ij_o@Zt()wN(*pM-kj}X2{y11k60fK6(6Fz9v%{Xc)FUq_0F%EsivrC^ z(855K_;Z7du#b6q1_9P|8~`j3ldye+|6qg)$X)=*J@M!##6|FdaTRDtctB1^En=(4 z5d%bpgOGOwlRS9?p(=r!1(I#X``e?;|de#1Jr9 z1wIjUqYA!)JXsX{awMsE>z*0dk?&Ih@zo1hUkZ8@mXLna7^u7Sec;HXS1u|hSbBq; z!>z9j+&bsP_IL%)%_|^0rZo5rY!gLq7x~-x8CIGr*W(qxDFswU%m*Vl0)k%PL_Ea| z)vq~#G-cUIP&>^K7)EN(Uld3z`XV=1$Gf&9gZF`3I`?d1TIq*FqA+O~h*q?H{bgZ}9%UCWV1}{6;kv{{6V#j}M%00>Ja1oIlB} zetWIl!$~(aD=xBtSMlESA5;=F^vQem*eTdf4skFcCH9qpw_wM?w(5NZWW*c#IEkDW zD}1#K9DgKLaYV^>O)e&VT>y zKr_VyRvaPeqhA68^>=@P9rHxWMdJKe-Os__)av2H4TB1fO)0=OU@?hzlo_r+&Pkwi zC1Q=LrwF8q{e5SEkgA2f@-a@TDA5lSN|d}^P><&bVhS!*)a{k~@cf6b_538Wh!$W? zz;5}s786GC>8JGPRMML3rqQLGnA43FsAHqp9QEOEvDZw8{vu&ut*F0ZXGRGNWKv4j z{82{6EC0(4lf%}3`0;@ZRX@^<0I9;~Urk?renP?zAKuBw2ilxGXYvz-Fx@rzPL8P}L5_0Mz=tgKC zM5wCQp!pCXvLc`!)T0ouhhBv+%WRT#`!(Xt$af+VN+%1-Mp^^?99j;=LjrUh%O()# zK-oxNg-8fG3(~uw9B2nbs2diNLmKD~)Y*^N1bL8FL*GG9An$`VBK;QB2-%P)M3Nag zi2Mp@DfB+{5X$t3uR`PkC?OI;@g&kSp<<+?ptI0NP%+BZL4>fv4NN@a`4ZXpB8gBK zmOY4Qh3-cBcTgu(gM14#fwUJYhi*jvbLbkRtDtv~ejeHhU4$Nj7D7W%G?o*JbTjk| zs0w9&ftEo1$gf3w6mb?}JL0b)BjkaOpzLwPYY}zOb4Z6nl23$DecN%y4EH@GuPHfM{oiVI1(%fJkU2&kO?&h&Ui9UaH|H7eTqH zNVT0A1{@IG0kzOI8;Fd|Tr#({t!+m%!?HrN*0r|OZtm9ZT3K6dwg1mEgGg5U-S6-F z`~F}5*XzH$eEVF^bHAN4=REJ{4G0JKzcI&XsVrFj6B?}K5nTeBalja+uKhqBKgGWH zgg$$*Eo$_6pm4(S6?0ck_Rpu-e$()6O(-J0JCk;0y*w$}IA&f;rc+#-2iFeCUti$FA3Vdqp5mhfJ%H1%&{+J5l&z$ zz8!cVcvgJ%8QOL%z{n+YiZJ#P!CVEX29WV_0VdP?V3K}04EP-II{;A;bilQz!F&YV zu$>NV1vikO6uC(*RQNCv&I)?581o z5txKQSPtj}dj|L|2avJz9bg#3{vG@q0a0N88t@N55P;0l0>CA}a{$83CG6lQAndCE z4Wzjf+>u}&2aJH93DR5)b_;+5`vSO5=IMKItr+kd@FU~@PJjx+JOX$V?3dvhVO$bM zRDgdIAOZYl0m!`i6@(cC`+udNc?II1h4BAf^~`@`&i<<@J9+eH^|)vF zF#j1Rqa6RR(Mdboe@_g(&b462NdDFU!MMC)8lhXPs#&o%IdCszt{wiB0C&Ra8~v<{ z+eTkx$z~F6?2UvM;=O;y${ z=xLb-qJ$0GTGQ`{#N3pp=nrOb*nVnWj`9;A!7WzAC(Y2}wr|23KmTnj+&_j7 zzIA^z_7rS&r1X80dUxBzv$wYf;qRKOR#tw*G@Q}N9^6QZztoC;pe2`X7^134{=X1DvQfdFoAV8sfV_wS2tciGK;rs9W>4Y4A z&{kvorcZ+BDPQqy`1qaFy?Bu7n-ztZb#J@exJQHc)_(WKJ>jAF?r%Rl$;4^!_Tej; zqLUNwz5=OGzMR1ao%J&}ig>L5Wo+}h&02gWWx>WZg?~xqF3n{p)oAb+mPLo6-BN7N zr+#zi*HiG-pEX(bQ z{#v*5^VGR-f090!_?Og|C#|_yxthlV%E38tub)iaU*1tS@10Lmd!pBfZ;b~MXXHmm z^B?;nb#+d^RVx9KVbxOyn5ZXGOJ<(V4KYo^Zy#Fp$7$aM<1KI5cgQyWJ@u@xWaguG z8Y>RYzxY6C1n#b#m$~JH2+R9k%=|~z1Z?}@!$eic1bjzfQgm8f1pYE{%egvLG#0cI zDo>>b;eAKumwvhmw&foB;^LD%(Kz&C(iY3*r-=@1G3jg*!Rl^#o1}}T(;WHQQ;do`-=A;y> z9Dnqi)1S7ewK)F0XCGQT5`$0tx@E&Hx=4KL>%&hbH>mJ`L{R@Dg8Cm3)SnhX{U6>o ze|XotelXY#Z=%OuM1TK1Gm?u&xcR&<$( zVbO6Ki3^)k5*UtiQ3aej$L6JVJCTl==49!ujiASWgKB!T&z#>~bt)(& zXw!>Sgll1E=HVjgkURH<&fe(v`80Q0bZhvaQ34-m(=5Y0Ln(u!f!Y{>I;?1)SHXyc z5s^`-rPFO(1p^!;q;_c$>_>IDY7u+1MV;+MG6`j##O*~?lykRx6^%<(qT%!wh5A;d zuGF`)lW!BZ_ZK~>i(4a%NDq4*leRe>TT?7s9CK{Tj`u#V-ZiDi+HREQo}cG=p0+M* z9PV{oHK#ua3N-fGt77~1=AtLV6hhcio@J#&@D0bpQuYu9Ms_#9ii$PT93JNR!9N_G zyP-P%FE97cZMh*`xRVVXIa8e$Jl9U%JD$L#%R7aMNj~7>Sdcq~k`s+BnO_ zD<*WMbgOPwmgKubF4Lh}B^xeQ&J-4=R$!&Osue-8t|Y#PJsoKdpCw(v`g(Kc3T2{26zc{uj3Mfso8EM$gI)5TC*m8 z9~(_yDB~VPlwF5u-i472g|VE*juFSRn8V16KywT+9@%lO23~ykiqT{?7Y7x{GQ&Nl zNfbBChCC)J@-*|X8cv%| z+xhqm1M{X37yDL>Xs2wN$vhlo>hN`xM{yT+3=2Gec2}b1G9C)MC{=cPicKwHZjPGz zz$@W4QG18g(O8Cc%B5|nkTpcO#VC11rlOQ)Wpyh-fG(k41jHj$g!Za_fQ6UYfU8G@GBv*8^S&?u3JqI2QG#M&Xxo-Q$N#g0?) zu8;}w4$ql6@}U7=o!TDOo)p8XIx*Uz>9xtcbcjsIY@zz-yzN*RQh0?{iVM!CoB}8j zWl}!mG)gcwW==xsdJZ!wa-1);IxU=AS}w zdecqp!+d<&GB!%#bWVDY+i?i0VloJzN!QEI;sgP=`+TMdEQHD91ZP+j=e~=jjEja4 z*Uc?iu&Zty4{8R_?sbD;CPg1GwPBr866{r;I*JTVMIx=7 z;qdmm%~o2SY2ZV2$QNhg>=Q)8NV{26Y@pTZ5-K~)m%DQiWl^l^>KbI4t#3sw^)iMX zvcXd_rwfkDby|#rMsML|uh}kZYYJEOA^HUeb81QvIuXT4F_ORlqkV#phPlYucr+a- zJ`}c77zRKXy<5>Ih)a|>Mki~8O`ybxhXm zzZASXp(7_Zy?eyG{rz)Twqs!kSA0<(*}JeK%o??Ir;T}$=h7Z6;zu~))->!satS#R z`C2PLrs0fLVBHQxGr0`hG=&}fH71V`xxYPUMUDXvIvQ0*e!)jlnTB(VMiDS1xGc!jH1{#F{N9S0cW7MNeZ2Y ze4$84TpFxUrWZ-F^H9$Z$f~}6F}!fziZo0Ld}cZXN3imeTnW6&$rgv!a#}V=&|#O4 zW*E2Ws7*pUdBHr{N5yO5K3^8iq%-1&bqeDFFI%rIvFUpE+wH^jJ`{C!j*$3?@C%ob z>EPm;UwoVsB}bm6C1uV+%W><5UpQQa<)T*8Y~kUvzknSocCim6Mxz{5t)&SL!4Ebo z=PS9yxSjFcA!dn;m1x^3Rzc5&x1p{muK?^(CDzT=AzDB|K52%LmYG!+suj4qN&HN0 zJgspR8GsJrD(MQto+`)wPTjqk}=&=V)(?4ISh9 zoi3S$vp9mYvk;ZStirS5{jmYf5PS4l2&k!#S_JfNHl~6ctzwE18aPdf3^|+{jYw@n zwO-iVWWo$XF+*K&fECRT@jecoMZ$nfDZp*Z4C^XyR>0>I7=Ap9>_p%c9vgrbkBXd; z9Rib&_CJY^Rag+qzZ5MOWztB`f6A-p**e+6H)18{(r3hpMzRv*GW(2(qvai}BTwCP zsaAXZc~^y!b?wx$E-J`?43+fe4j0Xv_OO)9_O7h`QDjU?rh3du%M;phFZux{!pkCy3a|=xy8xN@z-O**g*Dya ztH?}~x94iLh26UF98r2~H%#ZYzdvA{DiKUQT~p@4-5{MAn%j+bxcWGsj5m5KUBZ1K z(pH798<_^>Xpe?-XKNW<0xxUhX7fQDtPb%^yb34%=q>(nlqrd(NJR;&#Z z5-20GnURN4a-{;Mk)hq_r4Yit>4MV9Gc;wyGS+7>vCMSDYvEjLmde(#G7&=$BM)bw zRSXkmU=knYq?RHk7?_n8XR55q4iozHF-i~k@ShvL0;8PjVm&>J%!(GyD&G; zd$UBPD>N46jqKYUZ`DP4UER^O_J{#4_yJ|ylxXcYk#tRN;XBBUTsNyvrjyEHMZj@LdSzvKl@| zNenPLjtUfX=5$WzLyV9KZ(E0Nk1gz^sPx_G+CJ1afFys5Omk~Kt1Zw; zdLrv^nDhC}5MDm@YLRatEm{>cO-wshI=YH1k<#`32ZXK0KW+vc?-uSeLHvr8R}=~nG7QRt^4%Y)%-*36_{Fjw4uO!H9NfXH;u^ysag z>R{XD2xilf!%5#shBKL~M}9shOD9guMbl2pFn{K)Z?j>5M?dP(vAnbec??K#)L}yD8u&i z*st0oed%9Dh_mi)SsaGidt)6a(K3f+g6S0=#~!l{OE0uK%_=%Uc*e=8V6r`|zt&7j~&8Fv@R!oObio72{AG~2+@v|(9}wQ*_s5<&v3swiHUlU`t103<+N ziDh|dg=xX!)kGz-pnQ>O>DuzjHH0|Wv|_P!P3_`>rqxtvc2P~iirV^`tgK}cC_;cA zNvUa+8TLHkfwShJ>S?V9F{oLXR*Em>b#Xj{K@BVpOCXxLO&)od#>6Q#cm zB_(Up@+#NV0Vi$Aq9ql}thNoCDk1v%`iAvOH)K>TA&jhiOHHGxpvhKJ4vy@G+~TE~ zxp@sh&(c+`u`XJaQIL@h0+D=UfvveGff|{XnVs9XZdns#U%zh2qT(f4o3aUIs%GQThgt~h(ut~^7^LyJktg%(CF5zuPLZmXUWe4 z+E`^;ZTiwpwTuZ1omIb7_ znl(jB^MR4LI%CC>oMqPHg}Ffg%c?BSELppxcD48dpIeu;IJnl#*DIstATY_x}>0BqX}dwW2^bg0>lYs$(N*DlF{*0*k`$X#5% zcG+SJX}(JH@^Z`i4e9Cob#==tYAc~dazOKu&uT7B%U`^<47jlTg60kBC2Q(dFD{GH zHRNxotTtz-uU!VQo6@Sw&9<8Li=gov%hxZiELgE_Ih1eHy1LrMWd$oN;9pmmR=FW- z@wyBk0_GQ2mt|CBFEN3CR!Lr6RYO791_Qw&tII22 zmz`6Uk>5nfx{K4{<3V%YhJ2zoTDQ7*$%>k)yfP?jp>1Pxrg@3Ymaf(1Sgoa*4fXk@ zInY__EX76Dj~r92g$UzBYk`D$O|&-F%#Uf-!U!6ZtR=mBO|dq8K0hW{n|_WT)2jtu z;5fNjeoU?wsH05RzRa7i%hUpw?wU-kmLF58eP8|Wh}809BDGNApU|k~$24lotUo1DdxjsAs0E_p ze^a40gCA3&#(U97N_OM#A%=B|1W9NYMTGAY10A|dsLeiUJURh*R*M$ zFNFCqDoy+F`JYmz<;Rq1A0AvaCQRE*p2WtaX-ofq@HGrJkwiqi4cv^8z{W41jCqP| zikeCksmLz;KLefy5EkNhfJ-4P^IM`>wUdOn3VZ2a1YZm6rT;zH*MWTjU~LHR*C^To zevg2gkoyt=Ct%T`s{LiE9Su)hWNcLCerdK$Qc0aL*}2p|HRkAq2Qj3f`j%OG?{ zH~13*+t*;f&7T(`hY?M}fH-CX_|JqeGr%nddpIB;@GZ2%%U~Xau*JZhx(xG^{E=;` z;FQqJOHWpLpDL;pJ546r(&^imjB)$F;;5BHqS=VHxs|8spssZ;x znO1|j0%(~f;W?d#aD+#78`#Nw zY5|k1b7Xy)0B8f;0{N4?mIKa$+X_g7bmoBD1$IJ6C7kShz|G)Z4Q>b6mjc!THUs7Y zvH%AGq~-nuh=DXn*~zL;xLUBr%Xp-a#=A zOucIV?&y+xg*&bvaeeQW`JH!d-4?Q?7WP}Fx6K;k4sZQMQq!L857cS)!rb#lL|lTo zcQ!EhMr6!=2ZFY+*$uddO`a*h%hLU{`^;57H;A23h|l05Z1U9L;?-*dGKefUu__9XZ$? zfEa)mKuXF&euNi!C)j%c)c|NC|M-M^0r3B@e;!!CPF8{=5cVSgS$TZm_aR_1_(y_U z3U&c0!iE2kc}eCAyBJjHJ)>;P(W8 ztc2?UE#OZ$k>ojpa4AWR2JV}ly*fVnyO8gs#a_DAUT&eix2lkc`aV4?*q5fj!!}Vk@G|W+87ay50j$O=w_!|dX zq1nDH2DpsqQYY{h*$20&359U!uL;S}=Z*w5M>&4@APtvZ+Ykm0Vc}H;IQC-@bCeT0 zBnjYYQjtr??L;Ng*K#uu8Cj5c6j#UvZjX%c{@VIk;+OI+(D>ds4RX4081J!C(^q>gOOO>DR5RVDYOl;z8CI>5#(S3bBOZ<-++HQn(B!aRROHm@|@4 z6`%bL&?ui+OcDN@_Bleu10lXE%tuE+^KO3+FxQBy|ay08u1Q8yTz7Lc=r2Yp;SF*zFsbcuDO3xG<2s2J4X*Ot(FclAor(c@r!d}y@Uiv!mYy)6TM`B zCO|2BfGmf1ycAnYh;6X~>sxs_wucBo0)6hW&4fovv=)2hXq5+AoAuF?iw%F1@=UUdv@K8^o8*YdO4c&YT&N2$N1-U4)r4Pp?h)!H zSZvHv`2U61Nhcio04g02S0t>~3Rd{bd@(c_@Ksr6?N_l@xv=fi5Gs=uVjc%F>Q}dr z{Lj1tV-fu=8v4Xn8Vhv<&SGX06IClUG9bUnvv|ukX*+XO0wRes5`a1=DE~&dm2V3~ zFj22fXIWqHir5jN5*iC1&gcCNY7P3J(BB|$8VeQrQ~*Us5CpZc!t;NJk%Uit0PT$5 zj7~So@$Dzb1oEpzGAFZusR^=;K#2U=Qn6c3D5R8uh(D4>2W`&^xleYCrC=m<#%9*C zWzVWXZ}ZNp&|XW-5CnusVeGvA5pZkqNyPlOANR>4M@T?)N)#C-8m|{E9XqQe%m~tP)01Y8 zCu6Bbi5;0)xBWmadX(i@Ky>4-?NcrDKWI#;XqYpfQlR0`#!N6Lt|{AAJZQjGEnGh5)Vt9t5`y%znT*z(e3BY&R2_IRNsC<1@fE@G}4o zfSoYj-Un0z{0$9$8^PWNI0g2(U@ispft|2;vcUc_fG~Ngzzv_7{R}BPxM9l|!eEAi znF`PVtl*vjCSlwOVA=pb2RrFp1(<|2bqv4&rh?xP@dI-+m~Vp_4!8sCs{t#(UJQWk zEGQXp5O53lCji<3CIDeVmVvnf@F>6y$Od$Re=pzx0AU#t#^5gjMSxNO`3m+jfH02E zff)_>2jD2c3pfwB53m8Q_W_y!K>)%!{0Ptq_EbPQ*a@3459|j42LO`*p8zrewQwy0 z06*mXjJ017JAkm%62Y7fAZ)UIfURJ+fEfuOOvMKQ&x2nK;4VNfxZ}Yb1iTKY1T+Kg z1^)?vEdUR=HDF!_v;s!JZ2^$Sgge1)1gOAHm|oifr2wK8_ym|0V77so0{CyuKbU@$ zTs2Wm&PKR?G(yPO^fM+*Pn`Ar**7-%k{4zN-?C^S@T#Wz*^mL{x1g2F{BzJ+lkdW*8vK^Yv30IVYdPj0sj-7%zGiN|5J_3-$33z zh)>v>pMbds@G~gijry3^v@r`H++v91gm4}(mxB8=*nt=``=YuE0=tz^z_A-@Oww8)Bq2p~q@ z1I)-2DX5e^#NyQ>NExeiov@(4;W2 z@5SF<$nkynmIRyUKJ=Hn|E|FwHoZ9T$ET*?c-ykOj6q_&e($d$<94$6tMl|1ALmEl zPYSwru3R38<+C5%ewo(dZ+Z^p7IkUy+fw`i{V{e!2Erg~m{ zlBu0M5x@Gx{9ExKr(jk*b;&m8H>o|Jmz;Vho5u4jg^yRA{~-0X`=ca%kA&f`@ua^P z5Aqllu4fegaHLkfp!)O4lqvZ9P37ySX(wRA^LPIGfg@VnlOq12u1=&Uz>0v((N9AJ_d(`bKKyAKrfA z9|k%8!(mbF+SVxC`Gxc2N8b4+735EzO{s>BgkQ+opE>_o>g3L!mkjsB;MCuEJ}XWD z9mx5|o710-!E+uDp6srU#QP!-)i28k#RuMuwB=2b;4>etGl&)hAce(};B_m6DmvFgia(@U=*{K~gm@01*FSsJd4CK(`nSeA9u=nGUkbto`H`FOp-OIz=#GD+=4malM>AvatCm>rDI1IT zPiCqgdPIR&8#fH3?otE6aDh4E@;mrxQ==75GwxYwe;pS$tv1%Q@^#BdN8{ z=QpgZdOh_&`lkQUH~o*k=}+sM{!j0lf%E#t9W3zwd5WYxyw#)6_J#$|l@8=d9_=ep zc)!{y+Oh22K?7G6Jk5E$cZ%iV5S@d|F^KyNzCo#XPh9BK;;;cZ&?PS>K_23381KKY@zcMs4PPt&uKq9VXU4*dv zvU6Apa*OD+Ns~b7d^ATPU#=EuSlC)^|H5-?wn!)1HIhSi>j8|CWt^P$pLj4G9J=X|n2KX83#R zmfWgmhw)I4hkl#>Hjic{&x!lnOb_i?73m)74(-%Tv07d)fgf-A`WM7zI=Pck?4h$& z;RBip(&@rnpVu1s*m7I9k4xyEzC$>vLIy)m6&jLRa7h(^TZLH!n)hzE)@Lq9|q#)=_uAWh2;k1VclZ7mlNU!LPJ@$ zj+F%I)KghgSPW|Ek6sEpMckSrJ zJG-_BYuZxw3nrbyZl6h;m@RMs)TeQh2tJ0l_-N5?oAY6s_xetGaq;o?KIbPmIjonT z>hKKZBK#-C(HP&bbO|j0B`zrwC~;0EqtF?w6iU6bTsI??scsblg5VD*aqV`V%Z67V z+ZaKJ7)Fc}UeqF1(wQRS=wPH1+&(rfyU&IZTLVr*xjvU^+t? z;)A{bzK@*|(_Xa}aV8Mvf;nh5Qf9&Bz+7^K-rE`%Elzx6h&shJA$xHSFSLoHtiFXA z6Z7c7K64h%!N?$=B{PcBo`(cvg+S6ZVNs^X$9M{lb6Y)`DIlJwbQfkyY_j%*9;yX% ze;AMsi1b{c5xEE@4kJXtsX~MY+bY1}SYl-L^6+!j3_@XgCyy@JD3J_leHPX=AObxq zVl3wJCj0nfvg!360UJ#t0{cb}KFb-b$=R4kkL3DrW@!2=9ILQB zW6*p-`C_A2cbXTPQ+D-R$>)*n#fsCoqQ?v(nniudS-ZAs{l9SonH z!&lQvbtE$rAzgceg|lU`+MvT)m!{4Z8%lRNwJv^E1?+41jGDxW>?gx0gM;mwlJ=H` z>BpXte7UU;)$Q^=+{46A4|2kSulXQrKgU*N3Eg26oXEGii<1m#hUr3X0;c4^dzLr^ z2a4x-8yq1F>_IQeuv1u|s4nzqkk-*vA~(f+;ugC?PV(|_sa#kjMbe}2sb4~);pLAbO6^o2+>Pu?Z5t<| z&_#ub=3qQf0-??-3z5Pt#M1Hz3(^hqI0FZxOtYvxUWnfohlhjvFPTA^y^G;gZ3EVP z_2Ct!API)ApS~VP_9Z;n>|n;+Hy{7F~#UPPbBf{Jfm9^jMGR zkn+-Q#}TTqs4pfZY;ZWXlM_NYOA;-36-g>&m)N~Jc%`(@f`y{EUN_cs2&TU#77el) zb4su&%8I~n~L^0NqhmdaSIX)jn;fuajib42E1BF~F z0~e*04YGVPl6t(XT66{-ln-o0vRi4JWjotxompjno63PwaIdr8lkwMGYG%75{U>ueU?Vb#_h1H6T zb7)ZYLaa`072>cDJr<2Y2bZ^sDeAxuTrr0-Wpg2u1wO{2td$rJF{V&HNI60W$sA}v zpQnHcdfdij0Yy@b%+3((R=9Z?Z|lOeR;+at3vr9mrzt#0^LFidZ1ZigJ8Sst&32>9 zFvE~LsHUUc0)>GVpcV;e-!QsjlJop-Hyk?P%QlO%aVy8}R1|w`TNTs=cAl6m=9x1k ztX65<$Ef)7Y7e(IvBiv#?kN$S(0eEDlEqx zy_ORd2~Ns*6q#8!&ln}VMvQCSLcR+zZn!@y(TKJk!-fWfz?nD~M=@d$L^SBoNysXR zRfHLkZLjQ9vBBC@H$2a3lf|4*)US(Rs_1}wVJQRe(wF-8z)e)b-3}eq>IAw}AyB-4d=%%P;B_5J zpi!6)i8;7E!n;``vZImQ_t>+i5xZIEY*Bc(!!8TU%S~aRIaSrmz9{4S4W?{kKWDGc zkE!FRY(2bY5To;q&}XM5E@v*AA@1-fgyk+#rYxv1iFPPMZD@ZhCCy(!XD9kPeG)Hf z;{G%l5ZC{WN3Qb4H?lC0Fv$ zVC{fx_z|X1{HGhogX#GcM8{Y{ko8lsOh;$Ufg>}wBY0Jk=Czu#>S%@@p*fHv6lh#Q zGhDA2Ny9Llc_k4u)D{H#jb853bDBh?>)R#@8xD(pg70Q8P-u_Q%APTg)SB#lZo5jc znx?J@ocRhi7Kok3TCvuHW|pv$VOY8gcnMk+&zn(%SF45hv!V$c-xP;~24>5UOJ7nAHEt2=OICbwc_Z92P)IAz+cX+tnx=OR0()9#Y$vZmG zFRVek6}HHwKKzE8i|pi2gz3|?;q7$%Y_uS*w0j%k9^u00vO*4bvFDh(_d?t03*L)y zLesGLh~x$yus1PDm32do^u4A?5oK8=;Xenk&IvL+(mMJie1iC)c*m_Q)CQ7RJIH z@n9jxh1oFooa5MG7rW#%w%R3bO&A9o$c{_Hz9Dl zW(VBFej)9;{p;N8_V-G!+ppGNx9@RM*BzY?j&lrdp#xX0WpicMs0Yc%IWW$#`>An` zQzyqc&i{U#^R4Zm&Q3Re?H#P!d}l<*gnp2<(JnS5p?7ptGuYbe%;19L7=j%rYM3# z5%O517Bw{1$D-_L1Ru2+;;u|i3fLr#4J&IJZjV)DcPgUE_0bT-e{I9<)!;kty05r& z<$4IrW#6rco*wX~AvtD!<*dZmsO$sbyn-Y^{Mgl%O=YEb);7k<;42e1eHHniT$GUr zt81YEs*wqit0Ft~C^|wOIU7m1NPj^iXq88#dhTXbf4u&(Ue6|v^idIBP-8g!IUB(v z5}dgf9O@AWzD=##6RLO3)*pyZzOp|M{w#!tjfmsIkJffHN~G$oQ2m88{cuXM=gB~n zkAE}@G{jggyF>L~=j!_xBo96lh~oO<##{oe6o~QmMAe@a`u9rp&lMz(91FzQGZup$ zt%tt>0};LpQF%i3SL^iy70Fj#3Pd>kg9u}-8;Eq@1l7OKrAXb#W z`m$@;kY*n(SG4L%v>xWV{z5C2Jor{1$_M@^sv|E_&;@!{HQYxjVTt*dT?9*ne&jtW zxqq|B-%G;Z;h>uDwK^GXqq(Y8zEC}^A^NM^=;Y%+55(w$7=eOb>(}q~({~e9-JyC| zYxIX7pp)ObKM?8JAB{A=r+Py5uqNqGJVqzGyn#q>{2E!-_ zK%6UMaelnFwuS0pfzx|lh6)V^qI7(DtwPtL+|XZb6IAz2(8E%w-}@$1=|~`2|5&u2 z>@Zj`^@slq6*?M-@|-`4swdE4&<(1y&}H<$>asoYdRS@o{U=4qhd+kx+c2@tK`KAh zWv{4J&xYz@Dc1Ww6D6O$8i?`rSW$np%eFk{^&qb=*lzO-W=nw8?k`HeU#2FrobFKZyO|UOQ zRPInc+$Z#h4>8GScLt(d`azT%y6lTkI7LqncNG1zKB&?IfoR{1Mf=GvgZqyD_^VK% zeSs+VeN9?HHTaT$jzKr5JP%Py<|n)Cshd^Z@p`yn=?@eDIw>q>q0z(pZ=6Rjc|#^>ByO zpSdbde&xeJq_%(D*w5p;?BGPzd4(Qsr1~$MlH|YsEfB{$7UxI0?5PmdaEKmmxB8Kt zlH_e)2BQ4t2T^Y5vNu9imqYb%W7hXPBuPFf1o>O;>{zrP@3Ox}>*4mT7Y<31UvUSb z{M8>tHT;6V%b*)nzwuH^$-n9{m}PL^*ZM0Kz+^z;(R$4=SRBiuOTWSRDZdJ)gSl@D)d$$O82+d zDs(N%4P6EcV-L)-?GSA^!PU@MRNn|rqu;)+7OV+IW58ABcinS4TnZ$4r$7C&(UiZR(CW&v z>e4&E?-wZ9osFAnNsij}Yb#6El?Dp2zNU85*oAe)Rb?fOq;NH5jYXwJjYSarE^Qfz ziIi5ZEhljc@-t?nXzv+UnD3VYDpF4p_+BxtdH#>eK^p3w=}?@}x@qC||HGU{J#WaV za&2QE@Y?Uycg%ZSb$^ge;7Rs}IbHX^Av0*Vvc^j2Txf>x=XdQ&auRtsCfEOqjA45H zXvU-d|0fxbM*UYA`=7>tnD3bP4cT5FHQ%o-X@kc%3dEv_+fVAuiTJjX<12SO;I(8^Zh)p*MD4|cWGPWF z7S|WmZz7Yqc9Z{C9m#$}S^a2PwFxORjWdl}D5Z8Z`*9Z&XC}^^rM(BK<)*y6#mnY4 z_}MUz%-f6et&R++hoclF7Uc$f{O%7OCl+h zn9@xq3%P7bBH=cW|1$ka8;RAQl(lNw%{Qm`J>i4J=-(p0J88o$H{ZMwyhxJp=MTSN z#m1Z}|7BCH4X!tl0H#IcKk{b|xczo0ANZG#l`oKvKQVuKSJYG|tyq?~y2Kht4@$c% zkcr7Q+60-S?&Tywt3OrpU#8y;mIb5fgE`s=#9|^9HA##I%UBtaqM z3yK0px7%++!Q{{ibtyr}A4DONC>TO=RU(^2!6+5va!!GuwaDzwQ3Tb#h%MqXutso& znUqbY41|)P(%Hs5^iKPLE&b48$e&0JDMH;bRn z^s=C*n2b9@c5FDL7t0+5ueF8c;$(sbi#KLcD_NVlwv0#|HEZn#4|4=p9xi$FHktfb#p`toJjI~bAjT?wx7QI-tLz5W5^i1~NGl3CnKFix^k!@#X+AH| zv*vQO#k@x8aYcMNY|B{;1foJ`!Xw4%k+?3KcDTKA-fFJYtjt**Ew+rA?$H(tSv=W< z%!)l4ElGu66^vztD7bPtSHziiN8MOo>+?(OUP0W=I?b^pyb6~*We)R-Tth;rl!v`B zsfjs)V~s~L1)0lYMjK@U-fY(8h_@QC6P3plZqZ}~!3ee+Eb0rb(UdErMj@3qrH#IT z((c1fq`7=lt56kPyb7+;V@t{1GLJfsbJROzdaE_7(y^L3`hdgf6lqLy)@iQN8`e9D z8h6Nx3sI1SvH_hyqR?Se-hw-s%|#*s-kY3&svu1SY%LPj5{|;CGbzl)sFt^wleEe8 zMU6D2maOC?Tvmoa*+d$|Q`JdsajlWHx~%#B2??tDwyqob`TcIV~hU|5=Db?3O{ zj+k1|5)*klaayhBVoZ+Sig?81RLB(~w;fwGsq#^kG8t6!gj~5zAoux$im(M+&9w?L zF}Xn}R-p}iDwjWI^@%+p>_QM|af>3kv_$d@$LIIhaxOv9E;-IAsGKUdH0cp~M{#^n zrO|9w+B`fx$6=K@q*if0!4q;?1b(qBER||ldpRMYNP&K-K{Ii5&a6x!NP9Gr2RROf zJuQpJ&`w=AZoSAaH*4KtUSEzWnUcp%>3Ej4nUl;#)uwP(CC7G>?qI9SCo1T9ZCtI} zt5S=NCYge{gOjitaJelO32!{d5!U$3YO~8Ze075|CiSQsHi1{paX2!GygnE+vJzaW z!05Jy(|MVUDdm`4;fTUll-pP%IBt*CXmqtoo!D7Eoix})#junYX-Gy*QKe3s8s4`- z9W=R9If=h~GdJasrH$#RLL*|DIpK7|qR51zYTm2{eOls{sw6%|7mmrHv>1y;aR%MU zRW!9IrIuD>g4xWCWu0!lO>6c?(a=t<%@~Txq)OI)uG^>=X6-?-GmXwxY!%0BF>^e@ zTg|bV12KUm?(yIZ)DF8*q%wFVE$HgXj6tU^CJJfEAx=OYPuYx?pq{sh6H@rINrl!T z=FQ^7T3fO*pURrSS&DO6e>$eIIu;J&hS$-r8y{I)e*mbL0WHGOB9T zXuX$mQUZO(R}||qtXnuOIakovVl#^Gn!#12S_}e(O<)f(Z{b8;VQVs;OPhJyIcY&Q z;gS@k5eyM=HZHa4>^V7W2e;s~hpcgr!{}l*bA(xqPGgjrbKZ3vl{Y0fh3)n*ZwNHP(X3>~?}pc9y~CYuIVBc-wA9Ii~W9Bn!*YfY8U6)oVaA-NUUIU<(1024s(17S18sZlxt8Y7s+VEa-m;z*G*iDAz*X5<(8xYUDgq^ zSruYMQpwuepbxfWQr=*$d@o1nmsmm}ebmZIaFkxR#SwHlth@+1rZevK#7#y4Q_3~D z4IY7BkWjnmJkgR*sahjm{asv+QE7{&iiJ!Hrxx@EEJ;yX<>wVS2A{=OG$|7ymYGvj z$+bzZ%j!J1m*bDSO@Xvt>5_cN!T-D|Nh~PxzRS_ZVxpE-xyXz;Mr(~b0}5MEfbHky zdbuHE7V0o3DP$>=LLt#Ru>FuUoYQ-Axgt8O+7L@QQVO+~CFK}1ft1o|i4-M+W<--- zv!f7*7oGIR?-gqcmV#GdGvgFuDq~cl&FlOU2S+Y3s^WQ{U5Q?#7W-XFmoJoH{lL-1 z90{F1;Zd{Za)hm7Q`lG(=g>1l%B)kJaTZ%JNOP^SjK7eU+Azyzh5nF7pG_x}%mgQG ziiLazML~$E#o)Lrd=aU{CsFh`a32GaI)QID=5_4pO78e`OrrwfrSq0&38@-U80 zYKh032@y`M$o2YFa*rUD2-|RUevzs*7L=<^7_kAJC*lx9JSL2;R-?bzA_=<87|{hH5Y;oZxNkGrFJDmk)Wkj!R$UmW;e(po{TJNrWYk#^r)j+%wmx` zM%_)F>(+H1MW5HI>$=o+VK81}9^J*hgu(38xs27NE4#d+TV+-E9@RZ-dezqT?$fuv zU;l>20RuTrg9dYXLxv6;K4Rpki$;$bJMQ93#!r|ysd@6HQ>I=vjZXwZkys*?$rVbK zTBFtJ4MvmM(qgsQ9n)vboOStZr_1f}`uu@lC>)8#;)!G`oyq1}^M&FSSI(JxRcYS* zs~0R>bj{*xue*N9(q%U+U$OGWn^vt}v-aj&Ze6$jw%a${vGLALcinx@=6mnE|A8$J zKD2e)_J<#N^s&dEc=D;IpLzDV=i6R*@ueLvzw#=cNVnG3JyxNvd!Isu_9cHk4bSN1^qImtZGW^<_k`tOovY z_%mP)u=`-8u)DDARw#us-Ch}V2zst+ETcE#pTqwW^_cLlg?2;FBGw(Tr(l18KCR*~ z?nHbK^dj^jVmq;X2W%B&#_|D(JD@zuIj9o48?r$Xh*d=xU19CeOw^~x^%b-RdKapM z-o~;;&`r=7=usRaZJo!lukT=uu!m7^KKwV}$KV&?e+OR&zX$xQ;Qt1@4EhNB`4e#l zbT7(A=rCe$p!_EMhf#MB{51RzphoB$wCgEY+J;FiI{@8*vJbLg-8Ph`z`q{a3!OlH zdR=aXzQyvJVS`X7XdqM$b%r$1MbMrqJ>z!R9JU|Awz@!1L!Uvj5a&ZLK|`?J6^Lzu z{~+qp>*j>!U>_3^AJAT>FGCAkkLC1uM#E;XY$o(B%H5#Fs6PkxZY&EU{w4Gx%CAFl zXh9WwDq5)hU*lQ(KN*eHe={6w{yH8np#$>Lu2ZI7+&(5Ngd;`bp&i4L|DVR?6bF$_ zAA_-JXdj&Y+DGT4V2B-#jEoLGH$ollLp0hxMlJssq|WO`1}_+=k=08zYbIB94Ax~A zwGUT$*Jd*vjdgXuU^KqpF&aOtD9ba-nT%V@yOuG=SFy{I%<^)^YqWF%t)zoSLxZ3S zXg5@k9{nEdO=#Jh(W=+L9{@iITLDW)a>nKssy)G&FvdX(mOU<@7%Nz_th%gSUc{Wk6d`~ zEV~P%wd^3X95X8&q_3fYG-#l{Xh0r(+8O8o91BZ3Hoe%5SoStVJ9ZT1Ua)k~&;js1 zG#I)9q658Ol^nN^j%k)&`>Gh3IBEY^{_v~(%S~@{Ix^!T$iZ7Wxj`p&fN7V%K0fZGU>cbW(W``*S1qFf!MeGJHC@ZG*ZYMkj^u;L}Nw z9!oQ}KLzE}@ab*tT9kz-7ooY(PQ>2BI<$Q)u)AUDZFCuy?SQ1vozOn$DJY2L?_xQ< zo^-;cw|X4k)DdR~q2qry7@2sI?Z&+DS+>^&53^&=J|#Z=N@Z^`TUlEls?%C@Mv3m?%9?@A8wn3UE7~;-RB>0>#w`sjz`?Vc*YeN zR@%-z=H9dl54xAE#=W!C9UD7sz+f!Z)-C9vu50VKb(SiI5eug~ZGh66F)Vf^BQ>)R zv$~RT&5fOz>`EfOtUrs*e&YFR6iVLOE--DpVmS=MyStH=ILkYs#)%~jOg6i;AyTd{ zt0^*j;HgBjm(47%EG;`$g(Yo$Oyy(Qu~U6n)$A^b9xQfQ{XCYQU0V1kYZ#kkMwBi#D}jf}%d!2^dm@&tlI(l9Cvz;j^!2kW9O6c08MgMx zj^5ZlWBMspqO!E*bXR6IyRB?99;1@+==2)YT}^t?x{9k<&FnVj zqVlz6b9%5Avq_s52R9;H#~j8k4eHI>$}U~YX3b`oo*rD@T()yMYiuRs=)?W6lrefI z564^fJ8LYP(f@H)A2#{r9oE(|B4kafWRzY$$=c6uJAQ($(BrvU=J3k46)lz6W$ELK zhA%IZjKX3va679no3UgLYY%SuN$iH)x0W`-u8pj%l_l97^-RJh&(xF;L%Wque1%1f z*C7IL45BHrer1>T zUW0XATZi{88)+(E%f9X;lR2E-cGbS}>ayXBc-Z2>qr9DL5*WvtUD@_PRTXW6ub7I> zWm9ZCG))|8 z*lnM#V6Ckrx82@|GhtlZA7{|^sFIaulk1o9RfoQ@|#9+NLBK<5jauyOcaVyJTCylCw*~$0b|YWaF{&zGaQ0SRb+pcO8zPwDuKN zfKB#(PPcjW7#12~)=Y)1?9+`{r%e j#=DSFxFSOiDc@*yN_?Y0W{;;Ubpehgmi@ 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", np.uint32(0))]], 1000), + ( + [ + ("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, + )