diff --git a/cpp/benchmarks/io/parquet/parquet_reader_metadata.cpp b/cpp/benchmarks/io/parquet/parquet_reader_metadata.cpp index 28f5ada2adf8..a183ac65d5d1 100644 --- a/cpp/benchmarks/io/parquet/parquet_reader_metadata.cpp +++ b/cpp/benchmarks/io/parquet/parquet_reader_metadata.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -309,7 +309,7 @@ void BM_parquet_filter_name_resolution(nvbench::state& state) auto constexpr pass_read_limit = 0; // No column projection is requested, so the reader reads all columns; this isolates filter name - // resolution (named_to_reference_converter) from select_columns name scanning. + // resolution (`parquet_filter_normalizer`) from select_columns name scanning. auto read_opts = cudf::io::parquet_reader_options::builder(source_sink.make_source_info()) .use_arrow_schema(false) .build(); @@ -326,7 +326,7 @@ void BM_parquet_filter_name_resolution(nvbench::state& state) auto metadatas = cudf::io::read_parquet_footers(sources); // Reader construction resolves all referenced filter column names - // (named_to_reference_converter) and runs column selection. Construction throws if a + // (`parquet_filter_normalizer`) and runs column selection. Construction throws if a // referenced name is missing, so successful construction is the validation; has_next() is // intentionally not called so per-sample timing is not perturbed by row-group filter // evaluation over the wide predicate. diff --git a/cpp/include/cudf/ast/detail/expression_transformer.hpp b/cpp/include/cudf/ast/detail/expression_transformer.hpp index 012fb3958871..2ecfec01b84b 100644 --- a/cpp/include/cudf/ast/detail/expression_transformer.hpp +++ b/cpp/include/cudf/ast/detail/expression_transformer.hpp @@ -1,12 +1,14 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2024, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #pragma once #include +#include + namespace CUDF_EXPORT cudf { namespace ast::detail { /** @@ -50,6 +52,16 @@ class expression_transformer { virtual std::reference_wrapper visit(column_name_reference const& expr) = 0; virtual ~expression_transformer() {} + + protected: + /** + * @brief Visits each expression in `operands`. + * + * @param operands Expressions to visit + * @return References to transformed expressions + */ + [[nodiscard]] std::vector> visit_operands( + std::span const> operands); }; } // namespace ast::detail diff --git a/cpp/src/ast/expressions.cpp b/cpp/src/ast/expressions.cpp index f3126fa49cbd..af678a348909 100644 --- a/cpp/src/ast/expressions.cpp +++ b/cpp/src/ast/expressions.cpp @@ -31,6 +31,18 @@ operation::operation(ast_operator op, expression const& left, expression const& std::invalid_argument); } +std::vector> +detail::expression_transformer::visit_operands( + std::span const> operands) +{ + std::vector> transformed_operands; + transformed_operands.reserve(operands.size()); + for (auto const& operand : operands) { + transformed_operands.push_back(operand.get().accept(*this)); + } + return transformed_operands; +} + cudf::size_type literal::accept(detail::expression_parser& visitor) const { return visitor.visit(*this); diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index a9a758ecb6ff..6b0375c785c2 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -225,13 +225,11 @@ class bloom_filter_expression_converter : public equality_literals_collector { auto const input_op = expr.get_operator(); auto const operator_arity = cudf::ast::detail::ast_operator_arity(input_op); - // Unary operation + // Membership filters cannot evaluate unary operations. Visit operands and push always true if (operator_arity == 1) { - auto visit_operands_fn = [this](auto const& operands) { - return this->visit_operands(operands); - }; - return parquet::detail::apply_unary_membership_transform( - expr, _bloom_filter_expr, *_always_true, *this, visit_operands_fn); + std::ignore = this->visit_operands(expr.get_operands()); + _bloom_filter_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); + return *_always_true; } // Binary operation @@ -556,16 +554,4 @@ std::vector> equality_literals_collector::get_literal return std::move(_literals); } -std::vector> -equality_literals_collector::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; -} - } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu index 933dcd67a383..4fe472b65580 100644 --- a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu +++ b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu @@ -1361,13 +1361,11 @@ class dictionary_expression_converter : public equality_literals_collector { auto const input_op = expr.get_operator(); auto const operator_arity = cudf::ast::detail::ast_operator_arity(input_op); - // Unary operation + // Membership filters cannot evaluate unary operations. Visit operands and push always true if (operator_arity == 1) { - auto visit_operands_fn = [this](auto const& operands) { - return this->visit_operands(operands); - }; - return parquet::detail::apply_unary_membership_transform( - expr, _dictionary_expr, *_always_true, *this, visit_operands_fn); + std::ignore = this->visit_operands(expr.get_operands()); + _dictionary_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); + return *_always_true; } // Binary operation diff --git a/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp b/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp index 708561aad26e..6e81d1bf3a93 100644 --- a/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp +++ b/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp @@ -739,9 +739,10 @@ aggregate_reader_metadata::filter_row_groups_with_bloom_filters( } /** - * @brief Converts column named expression to column index reference expression + * @brief Converts named columns to index reference columns and pushes logical negations down to + * expression leaves */ -named_to_reference_converter::named_to_reference_converter( +parquet_filter_normalizer::parquet_filter_normalizer( std::optional> expr, table_metadata const& metadata, std::vector const& schema_tree, @@ -763,7 +764,7 @@ named_to_reference_converter::named_to_reference_converter( expr.value().get().accept(*this); } -std::reference_wrapper named_to_reference_converter::visit( +std::reference_wrapper parquet_filter_normalizer::visit( ast::column_reference const& expr) { // Map the column index to its name diff --git a/cpp/src/io/parquet/experimental/hybrid_scan_helpers.hpp b/cpp/src/io/parquet/experimental/hybrid_scan_helpers.hpp index 1d061a3bc42d..59591d438913 100644 --- a/cpp/src/io/parquet/experimental/hybrid_scan_helpers.hpp +++ b/cpp/src/io/parquet/experimental/hybrid_scan_helpers.hpp @@ -400,19 +400,20 @@ class dictionary_literals_collector : public equality_literals_collector { }; /** - * @brief Converts named columns to index reference columns + * @brief Converts named columns to index reference columns and pushes logical negations down to + * expression leaves */ -class named_to_reference_converter : public parquet::detail::named_to_reference_converter { +class parquet_filter_normalizer : public parquet::detail::parquet_filter_normalizer { public: - named_to_reference_converter() = default; + parquet_filter_normalizer() = default; - named_to_reference_converter(std::optional> expr, - table_metadata const& metadata, - std::vector const& schema_tree, - cudf::io::parquet_reader_options const& options, - bool case_sensitive_names); + parquet_filter_normalizer(std::optional> expr, + table_metadata const& metadata, + std::vector const& schema_tree, + cudf::io::parquet_reader_options const& options, + bool case_sensitive_names); - using parquet::detail::named_to_reference_converter::visit; + using parquet::detail::parquet_filter_normalizer::visit; /** * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) diff --git a/cpp/src/io/parquet/experimental/hybrid_scan_impl.cpp b/cpp/src/io/parquet/experimental/hybrid_scan_impl.cpp index 96ae63b61409..2a9962b11830 100644 --- a/cpp/src/io/parquet/experimental/hybrid_scan_impl.cpp +++ b/cpp/src/io/parquet/experimental/hybrid_scan_impl.cpp @@ -245,15 +245,15 @@ void hybrid_scan_reader_impl::reset_column_selection() _is_payload_columns_selected = false; } -std::pair> +std::pair> hybrid_scan_reader_impl::prepare_filter_and_output_types(parquet_reader_options const& options) { CUDF_EXPECTS(options.get_filter().has_value(), "Empty input filter expression encountered"); select_columns(read_columns_mode::FILTER_COLUMNS, options); - // Convert the input expression (must be done after column selection) - auto expr_conv = build_converted_expression(options); + // Normalize the input expression (must be done after column selection) + auto expr_conv = build_normalized_expression(options); auto output_dtypes = get_output_types(_output_buffers_template); return {std::move(expr_conv), std::move(output_dtypes)}; @@ -673,8 +673,8 @@ table_with_metadata hybrid_scan_reader_impl::materialize_filter_columns( prepare_materialization( read_columns_mode::FILTER_COLUMNS, row_group_indices.size(), options, stream, mr); - // Convert the input expression (must be done after prepare_materialization) - _expr_conv = build_converted_expression(options); + // Normalize the input expression (must be done after prepare_materialization) + _expr_conv = build_normalized_expression(options); // Return early if all rows are pruned if (are_all_rows_pruned(row_mask, stream)) { @@ -747,8 +747,8 @@ table_with_metadata hybrid_scan_reader_impl::materialize_all_columns( prepare_materialization( read_columns_mode::ALL_COLUMNS, row_group_indices.size(), options, stream, mr); - // Convert the input expression (must be done after prepare_materialization) - _expr_conv = build_converted_expression(options); + // Normalize the input expression after materialization preparation. + _expr_conv = build_normalized_expression(options); prepare_data(read_mode::READ_ALL, row_group_indices, column_chunk_data, {}); @@ -782,8 +782,8 @@ void hybrid_scan_reader_impl::setup_chunking_for_filter_columns( _input_pass_read_limit = pass_read_limit; _output_chunk_read_limit = chunk_read_limit; - // Convert the input expression (must be done after prepare_materialization) - _expr_conv = build_converted_expression(options); + // Normalize the input expression (must be done after prepare_materialization) + _expr_conv = build_normalized_expression(options); // Return early if all rows are pruned if (are_all_rows_pruned(row_mask, stream)) { @@ -949,8 +949,8 @@ void hybrid_scan_reader_impl::setup_chunking_for_all_columns( _input_pass_read_limit = pass_read_limit; _output_chunk_read_limit = chunk_read_limit; - // Convert the input expression (must be done after column selection) - _expr_conv = build_converted_expression(options); + // Normalize the input expression (must be done after column selection) + _expr_conv = build_normalized_expression(options); prepare_data(read_mode::CHUNKED_READ, row_group_indices, column_chunk_data, {}); } @@ -1090,7 +1090,7 @@ void hybrid_scan_reader_impl::reset_internal_state() _output_chunk_read_limit = 0; _strings_to_categorical = false; _reader_column_schema.reset(); - _expr_conv = named_to_reference_converter{}; + _expr_conv = parquet_filter_normalizer{}; _mr = cudf::get_current_device_resource_ref(); } @@ -1124,18 +1124,18 @@ void hybrid_scan_reader_impl::initialize_options(parquet_reader_options const& o _mr = mr; } -named_to_reference_converter hybrid_scan_reader_impl::build_converted_expression( +parquet_filter_normalizer hybrid_scan_reader_impl::build_normalized_expression( parquet_reader_options const& options) { - if (not options.get_filter().has_value()) { return named_to_reference_converter{}; } + if (not options.get_filter().has_value()) { return parquet_filter_normalizer{}; } table_metadata metadata; populate_metadata(metadata); - auto expr_conv = named_to_reference_converter(options.get_filter(), - metadata, - _extended_metadata->get_schema_tree(), - options, - options.is_enabled_case_sensitive_names()); + auto expr_conv = parquet_filter_normalizer(options.get_filter(), + metadata, + _extended_metadata->get_schema_tree(), + options, + options.is_enabled_case_sensitive_names()); CUDF_EXPECTS(expr_conv.get_converted_expr().has_value(), "Columns names in filter expression must be convertible to index references"); return expr_conv; diff --git a/cpp/src/io/parquet/experimental/hybrid_scan_impl.hpp b/cpp/src/io/parquet/experimental/hybrid_scan_impl.hpp index a6588f5c2ee8..ee69641e11b5 100644 --- a/cpp/src/io/parquet/experimental/hybrid_scan_impl.hpp +++ b/cpp/src/io/parquet/experimental/hybrid_scan_impl.hpp @@ -364,13 +364,13 @@ class hybrid_scan_reader_impl : public parquet::detail::reader_impl { rmm::device_async_resource_ref mr); /** - * @brief Convert the input filter expression such that all column name references are replaced - * with corresponding column references + * @brief Normalize input filter such that all column names are converted to index references and + * logical negations are pushed down to the leaves. * * @param options Reader options - * @return Converted expression + * @return Filter expression normalizer */ - [[nodiscard]] named_to_reference_converter build_converted_expression( + [[nodiscard]] parquet_filter_normalizer build_normalized_expression( parquet_reader_options const& options); /** @@ -406,12 +406,12 @@ class hybrid_scan_reader_impl : public parquet::detail::reader_impl { std::span const> row_group_indices) const; /** - * @brief Helper to prepare converted filter expression and output column data types + * @brief Helper to prepare a normalized filter expression and output column data types * * @param options Parquet reader options - * @return A pair of a converted filter expression and a vector of output column data types + * @return A pair of filter expression normalizer and output column data types */ - std::pair> + std::pair> prepare_filter_and_output_types(parquet_reader_options const& options); /** diff --git a/cpp/src/io/parquet/expression_transform_helpers.cpp b/cpp/src/io/parquet/expression_transform_helpers.cpp index 7d798e49e35a..bec42f2905a6 100644 --- a/cpp/src/io/parquet/expression_transform_helpers.cpp +++ b/cpp/src/io/parquet/expression_transform_helpers.cpp @@ -47,6 +47,79 @@ namespace { } // namespace +template +std::optional transform_operator(ast::ast_operator op) +{ + if constexpr (mode == operator_transform::INVERT) { + switch (op) { + case ast::ast_operator::LESS: return ast::ast_operator::GREATER; + case ast::ast_operator::GREATER: return ast::ast_operator::LESS; + case ast::ast_operator::LESS_EQUAL: return ast::ast_operator::GREATER_EQUAL; + case ast::ast_operator::GREATER_EQUAL: return ast::ast_operator::LESS_EQUAL; + default: return std::make_optional(op); + } + } else { + // mode == NEGATE + switch (op) { + case ast::ast_operator::LESS: return ast::ast_operator::GREATER_EQUAL; + case ast::ast_operator::GREATER: return ast::ast_operator::LESS_EQUAL; + case ast::ast_operator::LESS_EQUAL: return ast::ast_operator::GREATER; + case ast::ast_operator::GREATER_EQUAL: return ast::ast_operator::LESS; + case ast::ast_operator::EQUAL: return ast::ast_operator::NOT_EQUAL; + case ast::ast_operator::NOT_EQUAL: return ast::ast_operator::EQUAL; + default: return std::nullopt; + } + } +} + +template std::optional transform_operator( + ast::ast_operator op); +template std::optional transform_operator( + ast::ast_operator op); + +std::optional de_morgan_operator(ast::ast_operator op) +{ + switch (op) { + case ast::ast_operator::LOGICAL_AND: return ast::ast_operator::LOGICAL_OR; + case ast::ast_operator::LOGICAL_OR: return ast::ast_operator::LOGICAL_AND; + case ast::ast_operator::NULL_LOGICAL_AND: return ast::ast_operator::NULL_LOGICAL_OR; + case ast::ast_operator::NULL_LOGICAL_OR: return ast::ast_operator::NULL_LOGICAL_AND; + default: return std::nullopt; + } +} + +bool is_boolean_valued(ast::expression const& expr) +{ + using cudf::ast::ast_operator; + + // A literal knows its own type + if (auto const* literal = dynamic_cast(&expr); literal != nullptr) { + return literal->get_data_type().id() == cudf::type_id::BOOL8; + } + + // A column reference cannot be typed here - the normalizer runs before the output data types are + // known - so report it as not provably boolean + auto const* operation = dynamic_cast(&expr); + if (operation == nullptr) { return false; } + + switch (operation->get_operator()) { + case ast_operator::EQUAL: [[fallthrough]]; + case ast_operator::NOT_EQUAL: [[fallthrough]]; + case ast_operator::NULL_EQUAL: [[fallthrough]]; + case ast_operator::LESS: [[fallthrough]]; + case ast_operator::GREATER: [[fallthrough]]; + case ast_operator::LESS_EQUAL: [[fallthrough]]; + case ast_operator::GREATER_EQUAL: [[fallthrough]]; + case ast_operator::LOGICAL_AND: [[fallthrough]]; + case ast_operator::LOGICAL_OR: [[fallthrough]]; + case ast_operator::NULL_LOGICAL_AND: [[fallthrough]]; + case ast_operator::NULL_LOGICAL_OR: [[fallthrough]]; + case ast_operator::IS_NULL: [[fallthrough]]; + case ast_operator::NOT: return true; + default: return false; + } +} + unary_operand extract_unary_operand(ast::operation const& expr) { auto const& operands = expr.get_operands(); @@ -89,7 +162,7 @@ binary_operands extract_binary_operands(ast::operation const& expr) } } -named_to_reference_converter::named_to_reference_converter( +parquet_filter_normalizer::parquet_filter_normalizer( std::optional> expr, table_metadata const& metadata, bool case_sensitive_names) @@ -106,21 +179,21 @@ named_to_reference_converter::named_to_reference_converter( expr.value().get().accept(*this); } -std::reference_wrapper named_to_reference_converter::visit( +std::reference_wrapper parquet_filter_normalizer::visit( ast::literal const& expr) { _converted_expr = std::reference_wrapper(expr); return expr; } -std::reference_wrapper named_to_reference_converter::visit( +std::reference_wrapper parquet_filter_normalizer::visit( ast::column_reference const& expr) { _converted_expr = std::reference_wrapper(expr); return expr; } -std::reference_wrapper named_to_reference_converter::visit( +std::reference_wrapper parquet_filter_normalizer::visit( ast::column_name_reference const& expr) { // check if column name is in metadata @@ -137,13 +210,74 @@ std::reference_wrapper named_to_reference_converter::visi return std::reference_wrapper(_col_ref.back()); } -std::reference_wrapper named_to_reference_converter::visit( +std::optional> +parquet_filter_normalizer::push_down_negation(ast::expression const& operand) +{ + using cudf::ast::ast_operator; + + auto const* child_operation = dynamic_cast(&operand); + if (child_operation == nullptr) { return std::nullopt; } + + auto const op = child_operation->get_operator(); + auto const operands = child_operation->get_operands(); + + // `NOT(NOT(x))` is `x`, including when `x` is null, but only when `x` is boolean. + if (op == ast_operator::NOT and is_boolean_valued(operands.front().get())) { + return operands.front().get().accept(*this); + } + + // De Morgan's laws compute exact equivalences for both the null-propagating (`LOGICAL_*`) and the + // Kleene (`NULL_LOGICAL_*`) operators, including when an operand is null + if (auto const de_morgan_op = de_morgan_operator(op); de_morgan_op.has_value()) { + // Negate both operands before emplacing the parent, as negating them appends to `_operators` + auto const lhs = negate(operands.front().get()); + auto const rhs = negate(operands.back().get()); + _operators.emplace_back(de_morgan_op.value(), lhs, rhs); + return std::reference_wrapper(_operators.back()); + } + + // Complement equality operators (exact for floats too) as `NaN == x` is false and `NaN != x` is + // true. Ordering comparisons (every comparison with `NaN` is false) are excluded. + if (op == ast_operator::EQUAL or op == ast_operator::NOT_EQUAL) { + auto const negation = transform_operator(op).value(); + auto new_operands = visit_operands(operands); + _operators.emplace_back(negation, new_operands.front(), new_operands.back()); + return std::reference_wrapper(_operators.back()); + } + + return std::nullopt; +} + +std::reference_wrapper parquet_filter_normalizer::negate( + ast::expression const& operand) +{ + // Push negation down to operands + if (auto const negated = push_down_negation(operand); negated.has_value()) { + return negated.value(); + } + // No exact rewrite available, so convert the operand and wrap it back in a `NOT` + auto const new_operand = operand.accept(*this); + _operators.emplace_back(ast::ast_operator::NOT, new_operand); + return std::reference_wrapper(_operators.back()); +} + +std::reference_wrapper parquet_filter_normalizer::visit( ast::operation const& expr) { auto const operands = expr.get_operands(); auto op = expr.get_operator(); - auto new_operands = visit_operands(operands); auto const operator_arity = cudf::ast::detail::ast_operator_arity(op); + + // Push down negation to leaves so that downstream transformers don't have to handle `NOT` over + // rewritten operands + if (op == ast::ast_operator::NOT) { + if (auto const negated = push_down_negation(operands.front().get()); negated.has_value()) { + _converted_expr = negated.value(); + return negated.value(); + } + } + + auto new_operands = visit_operands(operands); if (operator_arity == 2) { _operators.emplace_back(op, new_operands.front(), new_operands.back()); } else if (operator_arity == 1) { @@ -153,18 +287,6 @@ std::reference_wrapper named_to_reference_converter::visi return std::reference_wrapper(_operators.back()); } -std::vector> -named_to_reference_converter::visit_operands( - cudf::host_span const> operands) -{ - std::vector> transformed_operands; - for (auto const& operand : operands) { - auto const new_operand = operand.get().accept(*this); - transformed_operands.push_back(new_operand); - } - return transformed_operands; -} - names_from_expression::names_from_expression( std::optional> expr, std::vector const& skip_names, @@ -219,7 +341,7 @@ std::reference_wrapper names_from_expression::visit( std::reference_wrapper names_from_expression::visit( ast::operation const& expr) { - visit_operands(expr.get_operands()); + std::ignore = visit_operands(expr.get_operands()); return expr; } @@ -229,12 +351,56 @@ std::vector names_from_expression::to_vector() && std::make_move_iterator(_column_names.end())}; } -void names_from_expression::visit_operands( - cudf::host_span const> operands) +offset_column_references::offset_column_references( + std::optional> expr, size_type offset) + : _offset{offset} { - for (auto const& operand : operands) { - operand.get().accept(*this); + if (not expr.has_value()) { return; } + if (offset == 0) { + _converted_expr = expr; + return; } + _converted_expr = expr.value().get().accept(*this); +} + +std::optional> +parquet_filter_normalizer::get_converted_expr() const +{ + return _converted_expr; +} + +std::optional> +offset_column_references::get_converted_expr() const +{ + return _converted_expr; +} + +std::reference_wrapper offset_column_references::visit( + ast::literal const& expr) +{ + return expr; +} + +std::reference_wrapper offset_column_references::visit( + ast::column_reference const& expr) +{ + return _tree.push( + ast::column_reference{expr.get_column_index() + _offset, expr.get_table_source()}); +} + +std::reference_wrapper offset_column_references::visit( + ast::operation const& expr) +{ + auto const new_operands = visit_operands(expr.get_operands()); + auto const arity = cudf::ast::detail::ast_operator_arity(expr.get_operator()); + if (arity == 1) { return _tree.push(ast::operation{expr.get_operator(), new_operands.front()}); } + return _tree.push(ast::operation{expr.get_operator(), new_operands.front(), new_operands.back()}); +} + +std::reference_wrapper offset_column_references::visit( + ast::column_name_reference const&) +{ + CUDF_FAIL("Column name references are not supported in column reference offsetter"); } [[nodiscard]] std::unordered_map map_column_indices_to_names( @@ -384,30 +550,4 @@ std::optional>> collect_filtered_row_group_in return {filtered_row_group_indices}; } -offset_column_references::offset_column_references( - std::optional> expr, size_type offset) - : _offset{offset} -{ - if (!expr.has_value()) { return; } - - if (offset == 0) { - _converted_expr = expr; - return; - } - _converted_expr = expr.value().get().accept(*this); -} - -std::reference_wrapper offset_column_references::visit( - ast::column_reference const& expr) -{ - _col_ref.emplace_back(expr.get_column_index() + _offset, expr.get_table_source()); - return _col_ref.back(); -} - -std::reference_wrapper offset_column_references::visit( - ast::column_name_reference const&) -{ - CUDF_FAIL("Column name references are not supported in offset_column_references"); -} - } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/expression_transform_helpers.hpp b/cpp/src/io/parquet/expression_transform_helpers.hpp index 98523892d3cb..f509e979b32f 100644 --- a/cpp/src/io/parquet/expression_transform_helpers.hpp +++ b/cpp/src/io/parquet/expression_transform_helpers.hpp @@ -88,75 +88,27 @@ enum class operator_transform : uint8_t { * untransformable operators are returned as is (no std::nullopt) */ template -[[nodiscard]] inline std::optional transform_operator(ast::ast_operator op) -{ - if constexpr (mode == operator_transform::INVERT) { - switch (op) { - case ast::ast_operator::LESS: return ast::ast_operator::GREATER; - case ast::ast_operator::GREATER: return ast::ast_operator::LESS; - case ast::ast_operator::LESS_EQUAL: return ast::ast_operator::GREATER_EQUAL; - case ast::ast_operator::GREATER_EQUAL: return ast::ast_operator::LESS_EQUAL; - default: return std::make_optional(op); - } - } else { - // mode == NEGATE - switch (op) { - case ast::ast_operator::LESS: return ast::ast_operator::GREATER_EQUAL; - case ast::ast_operator::GREATER: return ast::ast_operator::LESS_EQUAL; - case ast::ast_operator::LESS_EQUAL: return ast::ast_operator::GREATER; - case ast::ast_operator::GREATER_EQUAL: return ast::ast_operator::LESS; - case ast::ast_operator::EQUAL: return ast::ast_operator::NOT_EQUAL; - case ast::ast_operator::NOT_EQUAL: return ast::ast_operator::EQUAL; - default: return std::nullopt; - } - } -} +[[nodiscard]] std::optional transform_operator(ast::ast_operator op); /** - * @brief Handle unary operation transform for membership-based row group filters. i.e., bloom - * filter and dictionary page filter. + * @brief Returns the De Morgan operator for the given operator * - * @tparam VisitorType Type of the AST visitor that implements accept() - * @tparam VisitOperandsFn Callable matching `(host_span>) -> - * vector>` + * @param op Operator to transform + * @return De Morgan operator or std::nullopt + */ +[[nodiscard]] std::optional de_morgan_operator(ast::ast_operator op); + +/** + * @brief Returns whether an expression is boolean-valued * - * @param expr Unary operation to transform - * @param expr_tree The AST tree to push transformed expressions into - * @param always_true Reference to the always_true sentinel literal - * @param visitor The visitor used to accept column references - * @param visit_operands_fn Callable to visit operands and return the transformed operands - * @return Transformed expression or _always_true if the operation cannot be evaluated + * Comparison, logical and null-checking operations always yield `BOOL8`, and a literal knows its + * own type. A column reference cannot be typed here, as the filter is normalized before the output + * data types are known, so it is reported as not boolean. + * + * @param expr Expression to classify + * @return Whether the expression yields `BOOL8` */ -template -[[nodiscard]] inline std::reference_wrapper apply_unary_membership_transform( - ast::operation const& expr, - ast::tree& expr_tree, - std::reference_wrapper const always_true, - VisitorType& visitor, - VisitOperandsFn&& visit_operands_fn) -{ - auto const [kind, col_ref] = extract_unary_operand(expr); - - // For `op col` form, push the `_always_true` expression - if (kind == operand_kind::COLUMN_REF) { - col_ref->accept(visitor); - expr_tree.push(ast::operation{ast::ast_operator::IDENTITY, always_true}); - return always_true; - } - // For `op expr` form, visit operands and push expression - else { - auto new_operands = visit_operands_fn(expr.get_operands()); - if (&new_operands.front().get() == &always_true.get()) { - // Pass through the _always_true child operand as is - expr_tree.push(ast::operation{ast::ast_operator::IDENTITY, expr_tree.back()}); - return always_true; - } else { - auto const input_op = expr.get_operator(); - expr_tree.push(ast::operation{input_op, new_operands.front()}); - return expr_tree.back(); - } - } -} +[[nodiscard]] bool is_boolean_valued(ast::expression const& expr); /** * @brief Collects column names from the expression ignoring the `skip_names` @@ -199,24 +151,22 @@ class names_from_expression : public ast::detail::expression_transformer { [[nodiscard]] std::vector to_vector() &&; private: - void visit_operands( - cudf::host_span const> operands); - std::unordered_map _column_indices_to_names; std::unordered_set _column_names; column_path_set _skip_names; }; /** - * @brief Converts named columns to index reference columns + * @brief Converts named columns to index reference columns and pushes logical negations down to the + * leaves of the expression. */ -class named_to_reference_converter : public ast::detail::expression_transformer { +class parquet_filter_normalizer : public ast::detail::expression_transformer { public: - named_to_reference_converter() = default; + parquet_filter_normalizer() = default; - named_to_reference_converter(std::optional> expr, - table_metadata const& metadata, - bool case_sensitive_names); + parquet_filter_normalizer(std::optional> expr, + table_metadata const& metadata, + bool case_sensitive_names); /** * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) @@ -242,17 +192,40 @@ class named_to_reference_converter : public ast::detail::expression_transformer /** * @brief Returns the converted AST expression * - * @return AST operation expression + * @return Converted expression, if an input expression was provided */ [[nodiscard]] std::optional> get_converted_expr() - const - { - return _converted_expr; - } + const; protected: - std::vector> visit_operands( - cudf::host_span const> operands); + /** + * @brief Rewrites `NOT(operand)` into an equivalent expression with the negation pushed into + * `operand`'s own operands + * + * Only rewrites that are exact in every case cudf's AST evaluates are applied, as the converted + * expression also filters the decoded rows: + * + * - `NOT(NOT(x))` => `x`, but only when `x` is provably boolean. `NOT` yields `bool` for any + * operand, so for a non-boolean `x` the double negation means `x != 0` rather than `x` + * - De Morgan forms: `NOT(a AND b)` => `NOT(a) OR NOT(b)` for both the null-propagating + * (`LOGICAL_*`) and the Kleene (`NULL_LOGICAL_*`) operators + * - `NOT(a == b)` => `a != b` and vice versa + * - `NOT(IS_NULL(x))` and `NOT(NULL_EQUAL(a, b))` => left alone as they have no complement + * - Ordering comparisons (`<`, `>`, `<=`, `>=`) are *not* complemented as IEEE-754 makes every + * comparison with a `NaN` false, so `NOT(a < b)` is true while `a >= b` is not + * + * @param operand The operand of the `NOT` operation to rewrite + * @return The rewritten expression, or std::nullopt if no exact rewrite exists + */ + [[nodiscard]] std::optional> push_down_negation( + ast::expression const& operand); + + /** + * @brief Returns the converted negation of `operand`, pushing the negation down if possible and + * otherwise wrapping the converted operand in a `NOT` + */ + [[nodiscard]] std::reference_wrapper negate( + ast::expression const& operand); column_path_map _column_name_to_index; std::optional> _converted_expr; @@ -303,9 +276,6 @@ class equality_literals_collector : public ast::detail::expression_transformer { [[nodiscard]] std::vector> get_literals() &&; protected: - std::vector> visit_operands( - cudf::host_span const> operands); - cudf::host_span _output_dtypes; std::vector> _literals; @@ -318,26 +288,43 @@ class equality_literals_collector : public ast::detail::expression_transformer { * @brief Offsets every column referencein an expression by the specified value * */ -class offset_column_references : public named_to_reference_converter { +class offset_column_references : public ast::detail::expression_transformer { public: offset_column_references(std::optional> expr, size_type offset); - // Use `visit` overloads from named_to_reference_converter - using named_to_reference_converter::visit; + /** + * @copydoc ast::detail::expression_transformer::visit(ast::literal const& ) + */ + std::reference_wrapper visit(ast::literal const& expr) override; /** * @copydoc ast::detail::expression_transformer::visit(ast::column_reference const& ) */ std::reference_wrapper visit(ast::column_reference const& expr) override; + /** + * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + */ + std::reference_wrapper visit(ast::operation const& expr) override; + /** * @copydoc ast::detail::expression_transformer::visit(ast::column_name_reference const& ) */ std::reference_wrapper visit( ast::column_name_reference const& expr) override; + /** + * @brief Returns the converted AST expression + * + * @return Converted expression, if an input expression was provided + */ + [[nodiscard]] std::optional> get_converted_expr() + const; + private: + ast::tree _tree; + std::optional> _converted_expr; size_type _offset{0}; }; diff --git a/cpp/src/io/parquet/reader_impl.cpp b/cpp/src/io/parquet/reader_impl.cpp index bbcd6ec05f21..9e50dddf04b8 100644 --- a/cpp/src/io/parquet/reader_impl.cpp +++ b/cpp/src/io/parquet/reader_impl.cpp @@ -592,12 +592,11 @@ reader_impl::reader_impl(std::size_t chunk_read_limit, std::back_inserter(_output_buffers_template), [](auto const& buff) { return cudf::io::detail::inline_column_buffer::empty_like(buff); }); - // Save the name to reference converter to extract output filter AST in - // `preprocess_file()` and `finalize_output()` + // Save the normalized output filter for `preprocess_file()` and `finalize_output()`. table_metadata metadata; populate_metadata(metadata); _expr_conv = - named_to_reference_converter(options.get_filter(), metadata, _options.case_sensitive_names); + parquet_filter_normalizer(options.get_filter(), metadata, _options.case_sensitive_names); } void reader_impl::prepare_data(read_mode mode) diff --git a/cpp/src/io/parquet/reader_impl.hpp b/cpp/src/io/parquet/reader_impl.hpp index 4c8cf0003b52..bc9f8737c7f8 100644 --- a/cpp/src/io/parquet/reader_impl.hpp +++ b/cpp/src/io/parquet/reader_impl.hpp @@ -567,8 +567,8 @@ class reader_impl { bool output_dict_columns = false; } _options; - // name to reference converter to extract AST output filter - named_to_reference_converter _expr_conv{std::nullopt, table_metadata{}, true}; + // Converts the input filter to AST output filter. + parquet_filter_normalizer _expr_conv{std::nullopt, table_metadata{}, true}; std::vector> _sources; std::unique_ptr _metadata; diff --git a/cpp/src/io/parquet/stats_filter_helpers.cpp b/cpp/src/io/parquet/stats_filter_helpers.cpp index 55cc1d45dfca..fb5dbd3e1e1d 100644 --- a/cpp/src/io/parquet/stats_filter_helpers.cpp +++ b/cpp/src/io/parquet/stats_filter_helpers.cpp @@ -89,18 +89,6 @@ std::pair, bool> stats_columns_collector::get_stats_co return {std::move(_columns_mask), _has_is_null_operator}; } -std::vector> stats_columns_collector::visit_operands( - cudf::host_span const> operands) -{ - std::vector> transformed_operands; - std::transform(operands.begin(), - operands.end(), - std::back_inserter(transformed_operands), - [t = this](auto& operand) { return operand.get().accept(*t); }); - - return transformed_operands; -} - stats_expression_converter::stats_expression_converter(ast::expression const& expr, size_type num_columns, bool has_is_null_operator, diff --git a/cpp/src/io/parquet/stats_filter_helpers.hpp b/cpp/src/io/parquet/stats_filter_helpers.hpp index 22781c9fff1f..ed7ac756bd2a 100644 --- a/cpp/src/io/parquet/stats_filter_helpers.hpp +++ b/cpp/src/io/parquet/stats_filter_helpers.hpp @@ -340,9 +340,6 @@ class stats_columns_collector : public ast::detail::expression_transformer { std::pair, bool> get_stats_columns_mask() &&; protected: - std::vector> visit_operands( - cudf::host_span const> operands); - size_type _num_columns; private: diff --git a/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp b/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp index eee71f349ec7..0d51abda5eff 100644 --- a/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp +++ b/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp @@ -493,16 +493,15 @@ TEST_F(HybridScanFiltersTest, FilterRowGroupsWithComplexExpressions) EXPECT_EQ(reader->total_rows_in_row_groups(stats_filtered), 2 * rows_per_row_group); } - // Filter: NOT(col0 > 50 AND col0 < 100) - // NOT over a compound expression (LOGICAL_AND) cannot be negated, degrades to always_true. + // Filter: NOT(col0 != 50 AND col0 != 100) becomes col0 == 50 OR col0 == 100. Prunes RG0 and RG3. { auto literal_50_value = cudf::numeric_scalar(50, true, cudf::get_default_stream()); auto literal_50 = cudf::ast::literal(literal_50_value); auto literal_100_value = cudf::numeric_scalar(100, true, cudf::get_default_stream()); auto literal_100 = cudf::ast::literal(literal_100_value); - auto gt_50 = cudf::ast::operation(cudf::ast::ast_operator::GREATER, col_ref0, literal_50); - auto lt_100 = cudf::ast::operation(cudf::ast::ast_operator::LESS, col_ref0, literal_100); - auto inner = cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, gt_50, lt_100); + auto ne_50 = cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col_ref0, literal_50); + auto ne_100 = cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col_ref0, literal_100); + auto inner = cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, ne_50, ne_100); auto filter = cudf::ast::operation(cudf::ast::ast_operator::NOT, inner); options.set_filter(filter); reader->reset_column_selection(); @@ -510,11 +509,12 @@ TEST_F(HybridScanFiltersTest, FilterRowGroupsWithComplexExpressions) auto input_row_group_indices = reader->all_row_groups(options); auto stats_filtered = reader->filter_row_groups_with_stats( input_row_group_indices, options, cudf::get_default_stream()); - EXPECT_EQ(stats_filtered.size(), 4); + EXPECT_EQ(stats_filtered.size(), 2); } // Filter: NOT(NOT(col0 < 100) OR col0 > 150) - // Outer NOT wraps a compound expression (LOGICAL_OR), degrades to always_true. + // De Morgan plus double-negation returns col0 < 100 AND NOT(col0 > 150), stats transform: + // vmin < 100 AND vmin <= 150. Prunes RG2 (vmin=100) and RG3 (vmin=150). { auto literal_100_value = cudf::numeric_scalar(100, true, cudf::get_default_stream()); auto literal_100 = cudf::ast::literal(literal_100_value); @@ -531,7 +531,8 @@ TEST_F(HybridScanFiltersTest, FilterRowGroupsWithComplexExpressions) auto input_row_group_indices = reader->all_row_groups(options); auto stats_filtered = reader->filter_row_groups_with_stats( input_row_group_indices, options, cudf::get_default_stream()); - EXPECT_EQ(stats_filtered.size(), 4); + EXPECT_EQ(stats_filtered.size(), 2); + EXPECT_EQ(reader->total_rows_in_row_groups(stats_filtered), 2 * rows_per_row_group); } } @@ -1323,7 +1324,35 @@ TEST_F(HybridScanFiltersTest, FilterRowGroupsWithDictionary) EXPECT_EQ(result, expected); } + // Filtering - NOT(table[2] == "0100") + // Rewritten to table[2] != "0100". Every dictionary holds "0100" and nothing else, so all four + // row groups are pruned + { + auto str_literal_value = cudf::string_scalar("0100", true, stream); + auto str_literal = cudf::ast::literal(str_literal_value); + auto inner = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col2_ref, str_literal); + auto const filter_expression = cudf::ast::operation(cudf::ast::ast_operator::NOT, inner); + auto const options = + cudf::io::parquet_reader_options::builder().filter(filter_expression).build(); + auto const result = + filter_row_groups_with_dictionaries(datasource_ref, reader_ref, options, stream, mr); + auto const expected = std::vector{}; + EXPECT_EQ(result, expected); + + // `NOT(col == v)` and `col != v` are the same predicate and must prune identically + auto const not_equal = + cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col2_ref, str_literal); + auto const not_equal_options = + cudf::io::parquet_reader_options::builder().filter(not_equal).build(); + EXPECT_EQ(result, + filter_row_groups_with_dictionaries( + datasource_ref, reader_ref, not_equal_options, stream, mr)); + } + // Filtering - NOT(table[0] == 50) + // Rewritten to table[0] != 50, which prunes only when 50 is the *only* dictionary value. Row + // group 1 holds 50..99, so nothing is pruned - negating the membership result instead would prune + // it and drop its non-50 rows { auto uint_literal_value = cudf::numeric_scalar(50, true, stream); auto uint_literal = cudf::ast::literal(uint_literal_value); @@ -1333,48 +1362,61 @@ TEST_F(HybridScanFiltersTest, FilterRowGroupsWithDictionary) cudf::io::parquet_reader_options::builder().filter(filter_expression).build(); auto const result = filter_row_groups_with_dictionaries(datasource_ref, reader_ref, options, stream, mr); - auto const expected = std::vector{0, 2, 3}; + auto const expected = std::vector{0, 1, 2, 3}; EXPECT_EQ(result, expected); } - // Filtering - NOT(table[0] == 50) AND (table[0] NULL_EQUAL 100) + // Filtering - NOT(table[0] != 50) AND (table[0] NULL_EQUAL 100) + // Rewritten to (table[0] == 50) AND NULL_EQUAL(...). NULL_EQUAL has no dictionary transform and + // relaxes, so only the equality prunes, keeping the row group whose dictionary holds 50 { auto literal_50_value = cudf::numeric_scalar(50, true, stream); auto literal_50 = cudf::ast::literal(literal_50_value); auto literal_100_value = cudf::numeric_scalar(100, true, stream); auto literal_100 = cudf::ast::literal(literal_100_value); - auto eq_50 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col0_ref, literal_50); - auto not_eq_50 = cudf::ast::operation(cudf::ast::ast_operator::NOT, eq_50); + auto ne_50 = cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col0_ref, literal_50); + auto not_ne_50 = cudf::ast::operation(cudf::ast::ast_operator::NOT, ne_50); auto null_eq_100 = cudf::ast::operation(cudf::ast::ast_operator::NULL_EQUAL, col0_ref, literal_100); auto const filter_expression = - cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, not_eq_50, null_eq_100); + cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, not_ne_50, null_eq_100); auto const options = cudf::io::parquet_reader_options::builder().filter(filter_expression).build(); auto const result = filter_row_groups_with_dictionaries(datasource_ref, reader_ref, options, stream, mr); - auto const expected = std::vector{0, 2, 3}; + auto const expected = std::vector{1}; EXPECT_EQ(result, expected); } - // Filtering - NOT(table[0] == 50) OR NOT(table[2] == "0100") + // Filtering - NOT((table[0] != 50) AND (table[0] != 150)) + // De Morgan and the equality complement give (table[0] == 50) OR (table[0] == 150), keeping only + // the row groups whose dictionaries hold 50 and 150 { auto literal_50_value = cudf::numeric_scalar(50, true, stream); auto literal_50 = cudf::ast::literal(literal_50_value); - auto str_literal_value = cudf::string_scalar("0100", true, stream); - auto str_literal = cudf::ast::literal(str_literal_value); - auto eq_50 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col0_ref, literal_50); - auto not_eq_50 = cudf::ast::operation(cudf::ast::ast_operator::NOT, eq_50); - auto eq_str = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col2_ref, str_literal); - auto not_eq_str = cudf::ast::operation(cudf::ast::ast_operator::NOT, eq_str); - auto const filter_expression = - cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_OR, not_eq_50, not_eq_str); + auto literal_150_value = cudf::numeric_scalar(150, true, stream); + auto literal_150 = cudf::ast::literal(literal_150_value); + auto ne_50 = cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col0_ref, literal_50); + auto ne_150 = cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col0_ref, literal_150); + auto conjunction = cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, ne_50, ne_150); + auto const filter_expression = cudf::ast::operation(cudf::ast::ast_operator::NOT, conjunction); auto const options = cudf::io::parquet_reader_options::builder().filter(filter_expression).build(); auto const result = filter_row_groups_with_dictionaries(datasource_ref, reader_ref, options, stream, mr); - auto const expected = std::vector{0, 2, 3}; + auto const expected = std::vector{1, 3}; EXPECT_EQ(result, expected); + + // The De Morgan rewrite must prune exactly like the directly spelled disjunction + auto eq_50 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col0_ref, literal_50); + auto eq_150 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col0_ref, literal_150); + auto const disjunction = + cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_OR, eq_50, eq_150); + auto const disjunction_options = + cudf::io::parquet_reader_options::builder().filter(disjunction).build(); + EXPECT_EQ(result, + filter_row_groups_with_dictionaries( + datasource_ref, reader_ref, disjunction_options, stream, mr)); } } diff --git a/cpp/tests/io/parquet_reader_test.cpp b/cpp/tests/io/parquet_reader_test.cpp index 50e283b1e7c9..844cbae958b6 100644 --- a/cpp/tests/io/parquet_reader_test.cpp +++ b/cpp/tests/io/parquet_reader_test.cpp @@ -2349,6 +2349,155 @@ TEST_F(ParquetReaderTest, ExtendedFilterExpressions) } } +TEST_F(ParquetReaderTest, FilterNegationPushdown) +{ + // The reader rewrites the filter into an equivalent form with logical negations pushed down to + // the leaves, and uses that single rewritten expression both to prune row groups and to filter + // the decoded rows. Every rewrite must therefore be exact, including for nulls and NaNs, so each + // case below compares the reader's output against the unrewritten filter evaluated over the + // whole table. + auto constexpr num_rows = 20'000; + + // Nulls every 7th row so that nulls straddle row group boundaries + auto const valids = + cudf::detail::make_counting_transform_iterator(0, [](auto i) { return i % 7 != 0; }); + auto col_a = cudf::test::fixed_width_column_wrapper( + cuda::counting_iterator{0}, cuda::counting_iterator{num_rows}, valids); + auto col_b = cudf::test::fixed_width_column_wrapper( + cuda::counting_iterator{num_rows}, cuda::counting_iterator{2 * num_rows}, valids); + // Half the rows are NaN. Ordered comparisons against NaN are all false, so complementing them + // would not be an equivalence + auto const floats = cudf::detail::make_counting_transform_iterator( + 0, [](auto i) { return i % 2 == 0 ? NAN : static_cast(i); }); + auto col_c = cudf::test::fixed_width_column_wrapper(floats, floats + num_rows); + + auto const written_table = cudf::table_view{{col_a, col_b, col_c}}; + auto const filepath = temp_env->get_temp_filepath("FilterNegationPushdown.parquet"); + cudf::io::parquet_writer_options const out_opts = + cudf::io::parquet_writer_options::builder(cudf::io::sink_info{filepath}, written_table) + .row_group_size_rows(5'000) + .stats_level(cudf::io::statistics_freq::STATISTICS_ROWGROUP); + cudf::io::write_parquet(out_opts); + + auto const expect_matches_unrewritten = [&](cudf::ast::expression const& filter, + std::optional expected_row_groups = + std::nullopt) { + auto predicate = cudf::compute_column(written_table, filter); + auto expected = cudf::apply_boolean_mask(written_table, *predicate); + + cudf::io::parquet_reader_options const read_opts = + cudf::io::parquet_reader_options::builder(cudf::io::source_info{filepath}).filter(filter); + auto result = cudf::io::read_parquet(read_opts); + CUDF_TEST_EXPECT_TABLES_EQUAL(*result.tbl, *expected); + if (expected_row_groups.has_value()) { + EXPECT_EQ(result.metadata.num_row_groups_after_stats_filter, expected_row_groups); + } + }; + + auto col_ref_a = cudf::ast::column_reference(0); + auto col_ref_b = cudf::ast::column_reference(1); + auto col_ref_c = cudf::ast::column_reference(2); + + auto lit_10_value = cudf::numeric_scalar(10); + auto lit_10 = cudf::ast::literal(lit_10_value); + auto lit_50_value = cudf::numeric_scalar(50); + auto lit_50 = cudf::ast::literal(lit_50_value); + auto lit_150_value = cudf::numeric_scalar(150); + auto lit_150 = cudf::ast::literal(lit_150_value); + auto lit_nan_value = cudf::numeric_scalar(NAN, true); + auto lit_nan = cudf::ast::literal(lit_nan_value); + auto lit_f50_value = cudf::numeric_scalar(50.0f, true); + auto lit_f50 = cudf::ast::literal(lit_f50_value); + + auto a_eq_10 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col_ref_a, lit_10); + auto a_neq_10 = cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col_ref_a, lit_10); + auto a_lt_50 = cudf::ast::operation(cudf::ast::ast_operator::LESS, col_ref_a, lit_50); + auto a_gt_50 = cudf::ast::operation(cudf::ast::ast_operator::GREATER, col_ref_a, lit_50); + auto a_lt_150 = cudf::ast::operation(cudf::ast::ast_operator::LESS, col_ref_a, lit_150); + auto b_eq_10 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col_ref_b, lit_10); + + // NOT(NOT(col_a < 50)) - double negation elimination. Becomes col_a < 50 + { + auto not_lt = cudf::ast::operation(cudf::ast::ast_operator::NOT, a_lt_50); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, not_lt), 1); + } + + // Double negation over a non-boolean operand must NOT be eliminated. + { + auto not_a = cudf::ast::operation(cudf::ast::ast_operator::NOT, col_ref_a); + auto not_not_a = cudf::ast::operation(cudf::ast::ast_operator::NOT, not_a); + + // Eliminating these would hand the reader an INT32 predicate instead of a BOOL8 one + expect_matches_unrewritten(not_not_a); + + // Whereas the triple negation still folds, since NOT(col_a) is boolean + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, not_not_a)); + } + + // NOT(col_a == 10) and NOT(col_a != 10) - complemented equality. col_a != 10 prunes nothing and + // col_a == 10 keeps only the first row group + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, a_eq_10), 4); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, a_neq_10), 1); + + // De Morgan over the null-propagating operators + { + // Becomes col_a <= 50 OR col_a >= 150, all row groups kept + auto conjunction = + cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, a_gt_50, a_lt_150); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, conjunction), 4); + + // Becomes col_a <= 50 AND col_a != 10, only first row group kept + auto disjunction = cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_OR, a_gt_50, a_eq_10); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, disjunction), 1); + } + + // De Morgan over the Kleene operators, where a null operand does not always produce a null result + { + auto conjunction = + cudf::ast::operation(cudf::ast::ast_operator::NULL_LOGICAL_AND, a_eq_10, b_eq_10); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, conjunction)); + + auto disjunction = + cudf::ast::operation(cudf::ast::ast_operator::NULL_LOGICAL_OR, a_eq_10, b_eq_10); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, disjunction)); + } + + // NOT(col_c == NaN) - equality stays an exact complement under IEEE-754 + { + auto c_eq_nan = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col_ref_c, lit_nan); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, c_eq_nan)); + } + + // NOT(col_c < 50.0) - ordered comparisons against NaN are all false, so this must NOT become + // col_c >= 50.0 + { + auto c_lt_50 = cudf::ast::operation(cudf::ast::ast_operator::LESS, col_ref_c, lit_f50); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, c_lt_50)); + + // ... including underneath a De Morgan rewrite + auto conjunction = cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, c_lt_50, a_eq_10); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, conjunction)); + } + + // Operators with no complement are left intact + { + auto a_is_null = cudf::ast::operation(cudf::ast::ast_operator::IS_NULL, col_ref_a); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, a_is_null)); + + auto a_null_eq_10 = + cudf::ast::operation(cudf::ast::ast_operator::NULL_EQUAL, col_ref_a, lit_10); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, a_null_eq_10)); + } + + // Nested negations mixing all of the above + { + auto inner_or = cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_OR, a_eq_10, a_gt_50); + auto not_or = cudf::ast::operation(cudf::ast::ast_operator::NOT, inner_or); + auto outer_and = cudf::ast::operation(cudf::ast::ast_operator::LOGICAL_AND, not_or, a_lt_150); + expect_matches_unrewritten(cudf::ast::operation(cudf::ast::ast_operator::NOT, outer_and)); + } +} + TEST_F(ParquetReaderTest, FilterNamedExpression) { auto [src, filepath] = create_parquet_with_stats("NamedExpression.parquet"); diff --git a/python/cudf/cudf/tests/input_output/test_parquet.py b/python/cudf/cudf/tests/input_output/test_parquet.py index 4b0979e806e7..6df81526d377 100644 --- a/python/cudf/cudf/tests/input_output/test_parquet.py +++ b/python/cudf/cudf/tests/input_output/test_parquet.py @@ -4742,6 +4742,7 @@ def test_parquet_reader_mismatched_nullability_structs(tmp_path): "predicate,expected_len", [ ([[("str", "==", "FINDME")], [("fp64", "==", float(500))]], 2), + ([("str", "!=", "FINDME")], 998), ([("fixed_pt", "==", decimal.Decimal(float(500)))], 2), ([[("ui32", "==", np.uint32(500)), ("str", "==", "FINDME")]], 2), ([[("str", "==", "FINDME")], [("ui32", ">=", np.uint32(0))]], 1000), diff --git a/python/pylibcudf/tests/io/test_experimental_hybrid_scan.py b/python/pylibcudf/tests/io/test_experimental_hybrid_scan.py index 1c6dc4855b85..d1e4c1996985 100644 --- a/python/pylibcudf/tests/io/test_experimental_hybrid_scan.py +++ b/python/pylibcudf/tests/io/test_experimental_hybrid_scan.py @@ -708,6 +708,184 @@ def test_hybrid_scan_construct_row_group_passes( ) +def _col0_stats_negation_cases() -> list[ + tuple[str, Operation, Operation, list[int]] +]: + """Cases of (id, negated, equivalent unnegated, surviving row groups).""" + + def _literal(value: int) -> Literal: + return Literal( + plc.Scalar.from_arrow(pa.scalar(value, type=pa.uint32())) + ) + + lt_250 = Operation( + ASTOperator.LESS, ColumnNameReference("col0"), _literal(250) + ) + ge_250 = Operation( + ASTOperator.GREATER_EQUAL, + ColumnNameReference("col0"), + _literal(250), + ) + lt_500 = Operation( + ASTOperator.LESS, ColumnNameReference("col0"), _literal(500) + ) + ge_500 = Operation( + ASTOperator.GREATER_EQUAL, + ColumnNameReference("col0"), + _literal(500), + ) + lt_750 = Operation( + ASTOperator.LESS, ColumnNameReference("col0"), _literal(750) + ) + ge_750 = Operation( + ASTOperator.GREATER_EQUAL, + ColumnNameReference("col0"), + _literal(750), + ) + eq_100 = Operation( + ASTOperator.EQUAL, ColumnNameReference("col0"), _literal(100) + ) + ne_100 = Operation( + ASTOperator.NOT_EQUAL, + ColumnNameReference("col0"), + _literal(100), + ) + + return [ + # Collapses to col0 < 250 + ( + "double_negation", + Operation(ASTOperator.NOT, Operation(ASTOperator.NOT, lt_250)), + lt_250, + [0], + ), + # Becomes col0 == 100 + ( + "not_equal_to_equal", + Operation(ASTOperator.NOT, ne_100), + eq_100, + [0], + ), + # Becomes col0 != 100, which stats cannot prune with + ( + "equal_to_not_equal", + Operation(ASTOperator.NOT, eq_100), + ne_100, + [0, 1, 2, 3], + ), + # De Morgan over AND, giving col0 < 250 OR col0 >= 500 + ( + "de_morgan_and", + Operation( + ASTOperator.NOT, + Operation(ASTOperator.LOGICAL_AND, ge_250, lt_500), + ), + Operation(ASTOperator.LOGICAL_OR, lt_250, ge_500), + [0, 2, 3], + ), + # De Morgan over OR, giving col0 >= 250 AND col0 < 750 + ( + "de_morgan_or", + Operation( + ASTOperator.NOT, + Operation(ASTOperator.LOGICAL_OR, lt_250, ge_750), + ), + Operation(ASTOperator.LOGICAL_AND, ge_250, lt_750), + [1, 2], + ), + ] + + +@pytest.mark.parametrize( + "negated,unnegated,expected", + [ + pytest.param(negated, unnegated, expected, id=name) + for name, negated, unnegated, expected in _col0_stats_negation_cases() + ], +) +def test_hybrid_scan_filter_row_groups_with_stats_negation( + simple_hybrid_scan_reader: HybridScanReader, + simple_parquet_options: plc.io.parquet.ParquetReaderOptions, + negated: Operation, + unnegated: Operation, + expected: list[int], +) -> None: + """A negated filter must prune exactly like its unnegated equivalent.""" + + reader = simple_hybrid_scan_reader + + def prune(filter_expression: Operation) -> list[int]: + reader.reset_column_selection() + simple_parquet_options.set_filter(filter_expression) + all_row_groups = reader.all_row_groups(simple_parquet_options) + return reader.filter_row_groups_with_stats( + all_row_groups, simple_parquet_options + ) + + assert prune(negated) == expected + assert prune(unnegated) == expected + + +@pytest.mark.parametrize( + "negated,unnegated,expected", + [ + # col1 holds 250 distinct strings per row group, so `col1 != v` never + # sees a dictionary made up only of `v` and cannot prune + pytest.param( + ASTOperator.EQUAL, + ASTOperator.NOT_EQUAL, + [0, 1, 2, 3], + id="equal_to_not_equal", + ), + # `col1 == "str_0"` keeps only the row group whose dictionary holds it + pytest.param( + ASTOperator.NOT_EQUAL, + ASTOperator.EQUAL, + [0], + id="not_equal_to_equal", + ), + ], +) +def test_hybrid_scan_filter_row_groups_with_dictionary_pages_negation( + simple_parquet_bytes: bytes, + simple_hybrid_scan_reader: HybridScanReader, + simple_parquet_options: plc.io.parquet.ParquetReaderOptions, + negated: ASTOperator, + unnegated: ASTOperator, + expected: list[int], +) -> None: + """`NOT(col == v)` must prune what `col != v` prunes, and converse.""" + + col1 = ColumnNameReference("col1") + needle = Literal(plc.Scalar.from_arrow(pa.scalar("str_0"))) + reader = simple_hybrid_scan_reader + + def prune(filter_expression: Operation) -> list[int]: + reader.reset_column_selection() + simple_parquet_options.set_filter(filter_expression) + all_row_groups = reader.all_row_groups(simple_parquet_options) + _, dictionary_ranges = reader.secondary_filters_byte_ranges( + all_row_groups, simple_parquet_options + ) + dictionary_data = [ + plc.gpumemoryview( + rmm.DeviceBuffer.to_device( + simple_parquet_bytes[r.offset : r.offset + r.size], + plc.utils._get_stream(), + ) + ) + for r in dictionary_ranges + ] + synchronize_stream() + return reader.filter_row_groups_with_dictionary_pages( + dictionary_data, all_row_groups, simple_parquet_options + ) + + inner = Operation(negated, col1, needle) + assert prune(Operation(ASTOperator.NOT, inner)) == expected + assert prune(Operation(unnegated, col1, needle)) == expected + + def test_hybrid_scan_metadata_with_page_index( simple_parquet_bytes: bytes, simple_hybrid_scan_reader: HybridScanReader,