From eb6b67b9a65c5198be0821263c34c36bd2115bcc Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:13:54 +0000 Subject: [PATCH 1/4] Add Parquet pruning expression builder Build Parquet predicate pruning expressions from per-row-group summaries using shared traversal and combination rules. Relax unsupported operations instead of tracking duplicated always-true literals, and share the builder across statistics, bloom-filter, and dictionary-page converters. Existential pruning semantics require relaxation for unsupported operators and correct handling of conjunctions, disjunctions, and negation. This avoids pruning row groups that may contain matching rows while allowing constrained conjuncts to continue pruning when another conjunct is unconstrained. --- cpp/src/io/parquet/bloom_filter_reader.cu | 135 +++----- .../experimental/dictionary_page_filter.cu | 155 ++++----- .../experimental/hybrid_scan_helpers.cpp | 2 +- .../parquet/experimental/page_index_filter.cu | 27 +- .../parquet/expression_transform_helpers.cpp | 163 ++++++++++ .../parquet/expression_transform_helpers.hpp | 119 +++++++ cpp/src/io/parquet/predicate_pushdown.cpp | 8 +- cpp/src/io/parquet/stats_filter_helpers.cpp | 295 ++++++------------ cpp/src/io/parquet/stats_filter_helpers.hpp | 59 ++-- cpp/tests/io/parquet_reader_test.cpp | 5 +- 10 files changed, 547 insertions(+), 421 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index d758c6b71590..6119c24e8904 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -178,20 +178,15 @@ struct bloom_filter_caster { * @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 { +class bloom_filter_expression_converter : public parquet_expression_simplifier { public: bloom_filter_expression_converter( ast::expression const& expr, cudf::host_span output_dtypes, - cudf::host_span const> equality_literals, - cuda::stream_ref stream) - : _equality_literals{equality_literals}, - _always_true_scalar{std::make_unique>(true, true, stream)}, - _always_true{std::make_unique(*_always_true_scalar)} + cudf::host_span const> equality_literals) + : parquet_expression_simplifier{std::span{output_dtypes.data(), output_dtypes.size()}}, + _equality_literals{equality_literals} { - // Set the output data types - _output_dtypes = output_dtypes; - // Compute and store columns literals offsets _col_literals_offsets.reserve(static_cast(_output_dtypes.size()) + 1); _col_literals_offsets.emplace_back(0); @@ -204,93 +199,61 @@ class bloom_filter_expression_converter : public equality_literals_collector { static_cast(col_literal_map.size()); }); - // Add this visitor - expr.accept(*this); + _bloom_filter_expr = simplify_expr(expr); } /** - * @brief Delete equality literals getter as it's not needed in the derived class + * @brief Returns the AST to apply on bloom filter membership + * + * @return The membership expression, or std::nullopt if no row group can be pruned */ - [[nodiscard]] std::vector> get_equality_literals() && = delete; - - // Bring all overloads of `visit` from equality_predicate_collector into scope - using equality_literals_collector::visit; + [[nodiscard]] simplified_expression_opt get_bloom_filter_expr() const + { + return _bloom_filter_expr; + } + protected: /** - * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + * @copydoc parquet_expression_simplifier::simplify_comparison + * + * A bloom filter answers only "might this value be present", so equality is the one comparison + * it can evaluate. Every other node relaxes via the base class defaults, including `NOT`, whose + * membership answer cannot be complemented: `¬(some row is 5)` means "no row is 5", not "some + * row is not 5". */ - std::reference_wrapper visit(ast::operation const& expr) override + [[nodiscard]] simplified_expression_opt simplify_comparison(ast::ast_operator op, + ast::column_reference const& col_ref, + ast::literal const& literal) override { using cudf::ast::ast_operator; - auto const input_op = expr.get_operator(); - auto const operator_arity = cudf::ast::detail::ast_operator_arity(input_op); + if (op != ast_operator::EQUAL) { return std::nullopt; } - // Membership filters cannot evaluate unary operations. Visit operands and push always true - if (operator_arity == 1) { - std::ignore = this->visit_operands(expr.get_operands()); - _bloom_filter_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; - } + auto const col_idx = col_ref.get_column_index(); + auto const& equality_literals = _equality_literals[col_idx]; - // Binary operation - auto const [op, lhs_kind, rhs_kind, col_ref, literal] = extract_binary_operands(expr); - - // Push expressions for `col op lit` or `lit op col` forms - if (lhs_kind == operand_kind::COLUMN_REF and rhs_kind == operand_kind::LITERAL) { - col_ref->accept(*this); - - if (op == ast_operator::EQUAL) { - auto const col_idx = col_ref->get_column_index(); - auto const& equality_literals = _equality_literals[col_idx]; - auto col_literal_offset = _col_literals_offsets[col_idx]; - // Skip bloom filter probing for timestamp columns with empty vector of literals due to - // a timestamp scale mismatch — the literal can never match the native values. - if (cudf::is_timestamp(_output_dtypes[col_idx]) and equality_literals.empty()) { - return *_always_true; - } - - auto const literal_iter = - std::find(equality_literals.cbegin(), equality_literals.cend(), literal); - CUDF_EXPECTS(literal_iter != equality_literals.end(), - "Bloom filter expression converter encountered an unexpected literal"); - - col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); - auto const& value = _bloom_filter_expr.push(ast::column_reference{col_literal_offset}); - _bloom_filter_expr.push(ast::operation{ast_operator::IDENTITY, value}); - } else { - _bloom_filter_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; - } - } // Visit operands and push expression for `expr op expr` form - else if (lhs_kind == operand_kind::EXPRESSION and rhs_kind == operand_kind::EXPRESSION) { - auto new_operands = visit_operands(expr.get_operands()); - _bloom_filter_expr.push(ast::operation{op, new_operands.front(), new_operands.back()}); - } // Push _always_true for `col op col`, `expr op col`, `expr op lit` forms - else { - _bloom_filter_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; + // Skip bloom filter probing for timestamp columns with empty vector of literals due to + // a timestamp scale mismatch — the literal can never match the native values. + if (cudf::is_timestamp(_output_dtypes[col_idx]) and equality_literals.empty()) { + return std::nullopt; } - return _bloom_filter_expr.back(); - } + auto const literal_iter = + std::find(equality_literals.cbegin(), equality_literals.cend(), &literal); + CUDF_EXPECTS(literal_iter != equality_literals.end(), + "Bloom filter expression converter encountered an unexpected literal"); - /** - * @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(); + auto const col_literal_offset = + _col_literals_offsets[col_idx] + + static_cast(std::distance(equality_literals.cbegin(), literal_iter)); + auto const& value = _tree.push(ast::column_reference{col_literal_offset}); + return _tree.push(ast::operation{ast_operator::IDENTITY, value}); } private: std::vector _col_literals_offsets; cudf::host_span const> _equality_literals; - ast::tree _bloom_filter_expr; - std::unique_ptr> _always_true_scalar; - std::unique_ptr _always_true; + simplified_expression_opt _bloom_filter_expr; }; } // namespace @@ -413,6 +376,15 @@ std::optional>> aggregate_reader_metadata::ap std::reference_wrapper filter, cuda::stream_ref stream) const { + // Convert AST to BloomfilterAST expression with reference to bloom filter membership + // in above `bloom_filter_membership_table` + bloom_filter_expression_converter bloom_filter_expr_converter{ + filter.get(), output_dtypes, {literals}}; + + // Return early if bloom filters cannot prune any row groups using the filter + auto const bloom_filter_expr = bloom_filter_expr_converter.get_bloom_filter_expr(); + if (not bloom_filter_expr.has_value()) { return std::nullopt; } + // Number of input table columns auto const num_input_columns = static_cast(output_dtypes.size()); @@ -457,17 +429,10 @@ std::optional>> aggregate_reader_metadata::ap // 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(), output_dtypes, {literals}, stream}; - // 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); + return collect_filtered_row_group_indices( + bloom_filter_membership_table, bloom_filter_expr.value(), input_row_group_indices, stream); } equality_literals_collector::equality_literals_collector( diff --git a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu index 66aa588d2bdf..6b117fee7f41 100644 --- a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu +++ b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu @@ -1313,23 +1313,20 @@ struct dictionary_caster { } }; +using parquet::detail::parquet_expression_simplifier; +using parquet::detail::simplified_expression_opt; + /** * @brief Converts AST expression to dictionary membership (DictionaryAST) expression. * This is used in row group filtering based on equality predicate. */ -class dictionary_expression_converter : public equality_literals_collector { +class dictionary_expression_converter : public parquet_expression_simplifier { public: dictionary_expression_converter(ast::expression const& expr, - cudf::host_span output_dtypes, - cudf::host_span const> literals, - cuda::stream_ref stream) - : _literals{literals}, - _always_true_scalar{std::make_unique>(true, true, stream)}, - _always_true{std::make_unique(*_always_true_scalar)} + std::span output_dtypes, + cudf::host_span const> literals) + : parquet_expression_simplifier{output_dtypes}, _literals{literals} { - // Set the output data types - _output_dtypes = output_dtypes; - // Compute and store columns literals offsets _col_literals_offsets.reserve(static_cast(_output_dtypes.size()) + 1); _col_literals_offsets.emplace_back(0); @@ -1343,100 +1340,59 @@ class dictionary_expression_converter : public equality_literals_collector { static_cast(col_literal_map.size()); }); - // Add this visitor - expr.accept(*this); + _dictionary_expr = simplify_expr(expr); } /** - * @brief Delete equality literals getter as it's not needed in the derived class + * @brief Returns the AST to apply on dictionary membership + * + * @return The membership expression, or std::nullopt if no row group can be pruned */ - [[nodiscard]] std::vector> get_equality_literals() && = delete; - - // Bring all overloads of `visit` from equality_predicate_collector into scope - using equality_literals_collector::visit; + [[nodiscard]] simplified_expression_opt get_dictionary_expr() const { return _dictionary_expr; } + protected: /** - * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + * @copydoc parquet::detail::parquet_expression_simplifier::simplify_comparison + * + * Dictionary membership supports equality and inequality comparisons. Unsupported operations + * return std::nullopt. */ - std::reference_wrapper visit(ast::operation const& expr) override + [[nodiscard]] simplified_expression_opt simplify_comparison(ast::ast_operator op, + ast::column_reference const& col_ref, + ast::literal const& literal) override { using cudf::ast::ast_operator; - using parquet::detail::extract_binary_operands; - using parquet::detail::operand_kind; - - auto const input_op = expr.get_operator(); - auto const operator_arity = cudf::ast::detail::ast_operator_arity(input_op); - - // Membership filters cannot evaluate unary operations. Visit operands and push always true - if (operator_arity == 1) { - std::ignore = this->visit_operands(expr.get_operands()); - _dictionary_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; - } - // Binary operation - auto const [op, lhs_kind, rhs_kind, col_ref, literal] = extract_binary_operands(expr); - - // Push expressions for `col op lit` or `lit op col` forms - if (lhs_kind == operand_kind::COLUMN_REF and rhs_kind == operand_kind::LITERAL) { - col_ref->accept(*this); - - if (op == ast_operator::EQUAL or op == ast_operator::NOT_EQUAL) { - auto const col_idx = col_ref->get_column_index(); - auto const& equality_literals = _literals[col_idx]; - auto col_literal_offset = _col_literals_offsets[col_idx]; - auto const literal_iter = - std::find(equality_literals.cbegin(), equality_literals.cend(), literal); - CUDF_EXPECTS(literal_iter != equality_literals.end(), - "Dictionary expression converter encountered an unexpected literal"); - col_literal_offset += std::distance(equality_literals.cbegin(), literal_iter); - - auto const& value = _dictionary_expr.push(ast::column_reference{col_literal_offset}); - - if (op == ast_operator::NOT_EQUAL) { - // For NOT_EQUAL operator, simply evaluate boolean is_false(value) expression as - // NOT(value). The value indicates if the row group should be pruned (if the literal is - // present in the hash set and it's the only value in the hash set) - _dictionary_expr.push(ast::operation{ast_operator::NOT, value}); - } else { - // For EQUAL operator, evaluate boolean is_true(value) expression as IDENTITY(value) - // The value indicates if the row group should be kept (if the literal is present in the - // hash set) - _dictionary_expr.push(ast::operation{ast_operator::IDENTITY, value}); - } - } // For all other expressions, push the `_always_true` expression - else { - _dictionary_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; - } - } // Visit operands and push expression for `expr op expr` form - else if (lhs_kind == operand_kind::EXPRESSION and rhs_kind == operand_kind::EXPRESSION) { - auto new_operands = visit_operands(expr.get_operands()); - _dictionary_expr.push(ast::operation{op, new_operands.front(), new_operands.back()}); - } // Push _always_true for `col op col`, `expr op col`, `expr op lit` forms - else { - _dictionary_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; + if (op != ast_operator::EQUAL and op != ast_operator::NOT_EQUAL) { return std::nullopt; } + + auto const col_idx = col_ref.get_column_index(); + auto const& equality_literals = _literals[col_idx]; + auto const literal_iter = + std::find(equality_literals.cbegin(), equality_literals.cend(), &literal); + CUDF_EXPECTS(literal_iter != equality_literals.end(), + "Dictionary expression converter encountered an unexpected literal"); + + auto const col_literal_offset = + _col_literals_offsets[col_idx] + + static_cast(std::distance(equality_literals.cbegin(), literal_iter)); + auto const& value = _tree.push(ast::column_reference{col_literal_offset}); + + if (op == ast_operator::NOT_EQUAL) { + // For NOT_EQUAL operator, simply evaluate boolean is_false(value) expression as + // NOT(value). The value indicates if the row group should be pruned (if the literal is + // present in the hash set and it's the only value in the hash set) + return _tree.push(ast::operation{ast_operator::NOT, value}); } - return _dictionary_expr.back(); - } - - /** - * @brief Returns the AST to apply on dictionary membership. - * - * @return AST operation expression - */ - [[nodiscard]] std::reference_wrapper get_dictionary_expr() const - { - return _dictionary_expr.back(); + // For EQUAL operator, evaluate boolean is_true(value) expression as IDENTITY(value) + // The value indicates if the row group should be kept (if the literal is present in the + // hash set) + return _tree.push(ast::operation{ast_operator::IDENTITY, value}); } private: std::vector _col_literals_offsets; cudf::host_span const> _literals; - ast::tree _dictionary_expr; - std::unique_ptr> _always_true_scalar; - std::unique_ptr _always_true; + simplified_expression_opt _dictionary_expr; }; } // namespace @@ -1454,6 +1410,19 @@ aggregate_reader_metadata::apply_dictionary_filter( std::reference_wrapper filter, cuda::stream_ref stream) const { + CUDF_FUNC_RANGE(); + + // Convert AST to DictionaryAST expression with reference to dictionary membership + // in above `dictionary_membership_table` + dictionary_expression_converter dictionary_expr_converter{ + filter.get(), + output_dtypes, + cudf::host_span const>{literals.data(), literals.size()}}; + + // Dictionary membership cannot filter anything in the filter, all row groups survive + auto const dictionary_expr = dictionary_expr_converter.get_dictionary_expr(); + if (not dictionary_expr.has_value()) { return std::nullopt; } + // Number of input table columns auto const num_input_columns = static_cast(output_dtypes.size()); // Number of columns with dictionaries @@ -1519,19 +1488,11 @@ aggregate_reader_metadata::apply_dictionary_filter( // Create a table from columns auto const dictionary_membership_table = cudf::table(std::move(dictionary_membership_columns)); - // Convert AST to DictionaryAST expression with reference to dictionary membership - // in above `dictionary_membership_table` - dictionary_expression_converter dictionary_expr{ - filter.get(), - cudf::host_span{output_dtypes.data(), output_dtypes.size()}, - cudf::host_span const>{literals.data(), literals.size()}, - stream}; - // Filter dictionary membership table with the DictionaryAST expression and collect // filtered row group indices return parquet::detail::collect_filtered_row_group_indices( dictionary_membership_table, - dictionary_expr.get_dictionary_expr(), + dictionary_expr.value(), cudf::host_span const>{input_row_group_indices.data(), input_row_group_indices.size()}, stream); diff --git a/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp b/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp index faba65f4f33a..118429c7ab03 100644 --- a/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp +++ b/cpp/src/io/parquet/experimental/hybrid_scan_helpers.cpp @@ -308,7 +308,7 @@ std::unique_ptr aggregate_reader_metadata::build_all_true_row_mask { CUDF_FUNC_RANGE(); auto const num_rows = total_rows_in_row_groups(row_group_indices); - CUDF_EXPECTS(num_rows < std::numeric_limits::max(), + CUDF_EXPECTS(std::cmp_less_equal(num_rows, std::numeric_limits::max()), "Total rows in row groups exceed the cudf's column size limit. Retry with a smaller " "set of row groups", std::invalid_argument); diff --git a/cpp/src/io/parquet/experimental/page_index_filter.cu b/cpp/src/io/parquet/experimental/page_index_filter.cu index aad523b8dc9f..a1d7e8bd96ff 100644 --- a/cpp/src/io/parquet/experimental/page_index_filter.cu +++ b/cpp/src/io/parquet/experimental/page_index_filter.cu @@ -555,7 +555,7 @@ struct page_stats_to_row_mask_converter : public page_stats_caster { [[nodiscard]] std::unique_ptr operator()( cudf::size_type schema_idx, cudf::data_type dtype, - std::reference_wrapper filter, + std::reference_wrapper stats_expr, cuda::stream_ref stream, rmm::device_async_resource_ref mr) const { @@ -577,15 +577,10 @@ struct page_stats_to_row_mask_converter : public page_stats_caster { } auto page_stats_table = cudf::table(std::move(columns)); - // Converts AST to StatsAST with reference to min, max columns in above `stats_table`. - parquet::detail::stats_expression_converter const stats_expr{ - filter.get(), std::span{&dtype, 1}, stream}; // Filter the input table using AST expression and return the (BOOL8) predicate column. - auto const page_mask = cudf::detail::compute_column(page_stats_table, - stats_expr.get_stats_expr().get(), - stream, - cudf::get_current_device_resource_ref()); + auto const page_mask = cudf::detail::compute_column( + page_stats_table, stats_expr.get(), stream, cudf::get_current_device_resource_ref()); auto const page_indices = compute_page_indices_async( page_row_offsets, total_rows, stream, cudf::get_current_device_resource_ref()); @@ -877,6 +872,14 @@ std::unique_ptr aggregate_reader_metadata::build_row_mask_with_pag "set of row groups", std::invalid_argument); + // Convert the filter to an expression over page statistics + parquet::detail::stats_expression_converter const stats_expr_converter{filter.get(), + output_dtypes}; + + // Return early if statistics cannot prune any pages using the filter + auto const stats_expr = stats_expr_converter.get_stats_expr(); + if (not stats_expr.has_value()) { return build_all_true_row_mask(row_group_indices, stream, mr); } + auto const num_columns = output_dtypes.size(); // Get a boolean mask indicating which columns will participate in stats based filtering @@ -919,7 +922,7 @@ std::unique_ptr aggregate_reader_metadata::build_row_mask_with_pag stats_col, output_column_schemas.front(), output_dtypes.front(), - filter, + stats_expr.value(), stream, mr); } @@ -976,12 +979,8 @@ std::unique_ptr aggregate_reader_metadata::build_row_mask_with_pag auto page_stats_table = cudf::table(std::move(page_stats_columns)); - // Converts AST to StatsAST with reference to min, max columns in above `stats_table`. - parquet::detail::stats_expression_converter const stats_expr{filter.get(), output_dtypes, stream}; - // Filter the input table using AST expression and return the (BOOL8) predicate column. - return cudf::detail::compute_column( - page_stats_table, stats_expr.get_stats_expr().get(), stream, mr); + return cudf::detail::compute_column(page_stats_table, stats_expr.value().get(), stream, mr); } template diff --git a/cpp/src/io/parquet/expression_transform_helpers.cpp b/cpp/src/io/parquet/expression_transform_helpers.cpp index 283dc77da7f2..c81c23c569ef 100644 --- a/cpp/src/io/parquet/expression_transform_helpers.cpp +++ b/cpp/src/io/parquet/expression_transform_helpers.cpp @@ -484,6 +484,169 @@ std::reference_wrapper offset_column_references::visit( return column_indices_to_names; } +parquet_expression_simplifier::parquet_expression_simplifier( + std::span output_dtypes) + : _output_dtypes{output_dtypes} +{ +} + +simplified_expression_opt parquet_expression_simplifier::simplify_unary_op( + ast::ast_operator, ast::column_reference const&) +{ + return std::nullopt; +} + +simplified_expression_opt parquet_expression_simplifier::simplify_negated_unary_op( + ast::ast_operator, ast::column_reference const&) +{ + return std::nullopt; +} + +simplified_expression_opt parquet_expression_simplifier::simplify_negated_comparison( + ast::ast_operator, ast::column_reference const&, ast::literal const&) +{ + return std::nullopt; +} + +void parquet_expression_simplifier::validate_column_reference( + ast::column_reference const& col_ref) const +{ + CUDF_EXPECTS(col_ref.get_table_source() == ast::table_reference::LEFT, + "Parquet filter expressions only support left-table column references", + std::invalid_argument); + CUDF_EXPECTS(std::cmp_less(col_ref.get_column_index(), _output_dtypes.size()), + std::format("Parquet filter column index {} is out of range of {} output columns", + col_ref.get_column_index(), + _output_dtypes.size()), + std::out_of_range); +} + +void parquet_expression_simplifier::validate_operands(ast::expression const& expr) const +{ + // Validate column references and traverse operations. Literals don't need really validation. + if (auto const* col_ref = dynamic_cast(&expr); col_ref != nullptr) { + validate_column_reference(*col_ref); + } else if (auto const* operation = dynamic_cast(&expr); + operation != nullptr) { + for (auto const& operand : operation->get_operands()) { + validate_operands(operand.get()); + } + } else if (dynamic_cast(&expr) != nullptr) { + // Column name references must not exist in the normalized filter expression + CUDF_FAIL("Column name references are not supported in normalized Parquet filter expressions"); + } +} + +parquet_expression_simplifier::negation_result parquet_expression_simplifier::simplify_negation( + ast::expression const& operand) +{ + auto const* operation = dynamic_cast(&operand); + if (operation == nullptr) { return {.handled = false, .expr = std::nullopt}; } + + // Unary operation + if (cudf::ast::detail::ast_operator_arity(operation->get_operator()) == 1) { + auto const [kind, col_ref] = extract_unary_operand(*operation); + if (kind != operand_kind::COLUMN_REF) { return {.handled = false, .expr = std::nullopt}; } + return {.handled = true, + .expr = simplify_negated_unary_op(operation->get_operator(), *col_ref)}; + } + + // Binary operation + auto const [op, lhs_kind, rhs_kind, col_ref, literal] = extract_binary_operands(*operation); + if (lhs_kind != operand_kind::COLUMN_REF or rhs_kind != operand_kind::LITERAL) { + return {.handled = false, .expr = std::nullopt}; + } + return {.handled = true, .expr = simplify_negated_comparison(op, *col_ref, *literal)}; +} + +simplified_expression_opt parquet_expression_simplifier::combine_logical_operands( + ast::ast_operator op, simplified_expression_opt lhs, simplified_expression_opt rhs) +{ + using cudf::ast::ast_operator; + + switch (op) { + // An AND operand that cannot filter can be dropped. The remaining expression still keeps every + // row the filter might match. + case ast_operator::LOGICAL_AND: [[fallthrough]]; + case ast_operator::NULL_LOGICAL_AND: + if (lhs.has_value() and rhs.has_value()) { + return _tree.push(ast::operation{ast_operator::NULL_LOGICAL_AND, lhs.value(), rhs.value()}); + } + return lhs.has_value() ? lhs : rhs; + + // An OR operand that cannot filter must return std::nullopt. + case ast_operator::LOGICAL_OR: [[fallthrough]]; + case ast_operator::NULL_LOGICAL_OR: + if (lhs.has_value() and rhs.has_value()) { + return _tree.push(ast::operation{ast_operator::NULL_LOGICAL_OR, lhs.value(), rhs.value()}); + } + return std::nullopt; + + default: CUDF_UNREACHABLE("Invalid operator for expression combination"); + } +} + +simplified_expression_opt parquet_expression_simplifier::simplify_expr(ast::expression const& expr) +{ + // Validate operands and simplify the expression + validate_operands(expr); + return simplify_expr_impl(expr); +} + +simplified_expression_opt parquet_expression_simplifier::simplify_expr_impl( + ast::expression const& expr) +{ + using cudf::ast::ast_operator; + + auto const* operation = dynamic_cast(&expr); + + // A column reference or literal cannot be simplified + if (operation == nullptr) { return std::nullopt; } + + auto const input_op = operation->get_operator(); + + // Unary operation + if (cudf::ast::detail::ast_operator_arity(input_op) == 1) { + auto const [kind, col_ref] = extract_unary_operand(*operation); + + if (kind == operand_kind::COLUMN_REF) { return simplify_unary_op(input_op, *col_ref); } + + // `parquet_filter_normalizer` has already pushed negations to the leaves, but only where an + // exact rewrite exists, so `NOT` over an operation still reaches here. + if (input_op == ast_operator::NOT) { + auto const [handled, negated] = simplify_negation(operation->get_operands().front().get()); + if (handled) { return negated; } + } + + return std::nullopt; + } + + auto const& operands = operation->get_operands(); + + // Combine simplified logical operands. + switch (input_op) { + case ast_operator::LOGICAL_AND: [[fallthrough]]; + case ast_operator::NULL_LOGICAL_AND: [[fallthrough]]; + case ast_operator::LOGICAL_OR: [[fallthrough]]; + case ast_operator::NULL_LOGICAL_OR: { + auto lhs = simplify_expr_impl(operands.front().get()); + auto rhs = simplify_expr_impl(operands.back().get()); + return combine_logical_operands(input_op, lhs, rhs); + } + default: break; + } + + // Binary operation, with `lit op col` normalized to `col op lit` + auto const [op, lhs_kind, rhs_kind, col_ref, literal] = extract_binary_operands(*operation); + + if (lhs_kind == operand_kind::COLUMN_REF and rhs_kind == operand_kind::LITERAL) { + return simplify_comparison(op, *col_ref, *literal); + } + + // Other binary expressions cannot be evaluated against chunk summaries. + return std::nullopt; +} + [[nodiscard]] std::vector get_column_names_in_expression( std::optional> expr, std::vector const& skip_names, diff --git a/cpp/src/io/parquet/expression_transform_helpers.hpp b/cpp/src/io/parquet/expression_transform_helpers.hpp index 8abdc9bd5b1a..2b7b67ec4e11 100644 --- a/cpp/src/io/parquet/expression_transform_helpers.hpp +++ b/cpp/src/io/parquet/expression_transform_helpers.hpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -329,6 +330,124 @@ class offset_column_references : public ast::detail::expression_transformer { size_type _offset{0}; }; +/** + * @brief Simplified normalized Parquet filter expression. std::nullopt means no such expression was + * derived + */ +using simplified_expression_opt = std::optional>; + +/** + * @brief Simplifies a normalized Parquet filter expression for row-group or page pruning + * + * This base class handles expression traversal and combination. Derived classes implement supported + * leaf operations and return std::nullopt for unsupported ones. + * + * The result indicates whether a row group or page might contain matching rows. Conjunction and + * disjunction combine partial results. Unsupported operators return std::nullopt (relax). + * + * | node | rule | + * | ------------------- | ---------------------------------------------------------------- | + * | `col op lit` | `simplify_comparison` | + * | `op(col)` | `simplify_unary_op` | + * | `NOT(op(col))` | `simplify_negated_unary_op` | + * | `NOT(col op lit)` | `simplify_negated_comparison` | + * | `a AND b` | both present => `AND`; one present => that one; neither => relax | + * | `a OR b` | both present => `OR`; otherwise relax | + * | anything else | std::nullopt | + * + */ +class parquet_expression_simplifier { + protected: + explicit parquet_expression_simplifier(std::span output_dtypes); + + ~parquet_expression_simplifier() = default; + + parquet_expression_simplifier(parquet_expression_simplifier const&) = delete; + parquet_expression_simplifier& operator=(parquet_expression_simplifier const&) = delete; + + /** + * @brief Simplifies a `col op lit` comparison + * + * @param op Comparison operator, normalized so that the column is the left operand + * @param col_ref Column being compared + * @param literal Literal being compared against + * @return Simplified expression, or std::nullopt if the input expression filters nothing + */ + [[nodiscard]] virtual simplified_expression_opt simplify_comparison( + ast::ast_operator op, ast::column_reference const& col_ref, ast::literal const& literal) = 0; + + /** + * @brief Simplifies a `NOT(col op lit)` comparison + * + * @return Simplified expression, or std::nullopt if the input expression filters nothing + */ + [[nodiscard]] virtual simplified_expression_opt simplify_negated_comparison( + ast::ast_operator op, ast::column_reference const& col_ref, ast::literal const& literal); + + /** + * @brief Simplifies an `op(col)` unary operation + * + * @return Simplified expression, or std::nullopt if the input expression filters nothing + */ + [[nodiscard]] virtual simplified_expression_opt simplify_unary_op( + ast::ast_operator op, ast::column_reference const& col_ref); + + /** + * @brief Simplifies a `NOT(op(col))` unary operation + * + * @return Simplified expression, or std::nullopt if the input expression filters nothing + */ + [[nodiscard]] virtual simplified_expression_opt simplify_negated_unary_op( + ast::ast_operator op, ast::column_reference const& col_ref); + + /** + * @brief Simplifies `expr` for filtering row groups or pages + * + * @param expr Filter expression, already normalized into negation normal form + * @return Simplified expression, or std::nullopt if the input expression filters nothing + */ + [[nodiscard]] simplified_expression_opt simplify_expr(ast::expression const& expr); + + /** + * @brief Validates a column reference + */ + void validate_column_reference(ast::column_reference const& col_ref) const; + + std::span _output_dtypes; + ast::tree _tree; + + private: + /** + * @brief Result of simplifying a `NOT` operand + */ + struct negation_result { + bool handled; ///< Indicates whether a negated unary or comparison operation was simplified + simplified_expression_opt expr; ///< Simplified expression, or std::nullopt otherwise + }; + + /** + * @brief Simplifies a `NOT` operation + */ + [[nodiscard]] negation_result simplify_negation(ast::expression const& operand); + + /** + * @brief Implementation of recursive simplification of `expr` for filtering row groups or pages + */ + [[nodiscard]] simplified_expression_opt simplify_expr_impl(ast::expression const& expr); + + /** + * @brief Combines the simplified expressions of a binary operation's operands + */ + [[nodiscard]] simplified_expression_opt combine_logical_operands(ast::ast_operator op, + simplified_expression_opt lhs, + simplified_expression_opt rhs); + + /** + * @brief Validates operands in `expr` + */ + void validate_operands(ast::expression const& expr) const; +}; + /** * @brief Maps indices of (all or selected) columns to their names * diff --git a/cpp/src/io/parquet/predicate_pushdown.cpp b/cpp/src/io/parquet/predicate_pushdown.cpp index 14c3644abf80..fe4bf39469f6 100644 --- a/cpp/src/io/parquet/predicate_pushdown.cpp +++ b/cpp/src/io/parquet/predicate_pushdown.cpp @@ -144,11 +144,15 @@ std::optional>> aggregate_reader_metadata::ap auto stats_table = cudf::table(std::move(columns)); // Converts AST to StatsAST with reference to min, max columns in above `stats_table`. - stats_expression_converter const stats_expr{filter.get(), output_dtypes, stream}; + stats_expression_converter const stats_expr{filter.get(), output_dtypes}; + + // Return early if statistics cannot prune any row groups using the filter + auto const converted_expr = stats_expr.get_stats_expr(); + if (not converted_expr.has_value()) { return std::nullopt; } // Filter stats table with StatsAST expression and collect filtered row group indices return collect_filtered_row_group_indices( - stats_table, stats_expr.get_stats_expr(), input_row_group_indices, stream); + stats_table, converted_expr.value(), input_row_group_indices, stream); } std::pair>>, surviving_row_group_metrics> diff --git a/cpp/src/io/parquet/stats_filter_helpers.cpp b/cpp/src/io/parquet/stats_filter_helpers.cpp index 1a1ddb045c13..fb5d81df41ad 100644 --- a/cpp/src/io/parquet/stats_filter_helpers.cpp +++ b/cpp/src/io/parquet/stats_filter_helpers.cpp @@ -16,23 +16,6 @@ namespace cudf::io::parquet::detail { namespace { -/** - * @brief Maps a logical connective to its null-aware equivalent, returning any other operator as is - * - * A null in a statistics column means the writer did not record the statistic, so it reads as - * "unknown, keep this chunk". The null-aware connectives keep a decisive verdict decisive - * (`false AND unknown` is false); the plain ones return null if either side is null, letting one - * absent statistic switch off pruning for the whole expression. - */ -[[nodiscard]] ast::ast_operator null_aware_operator(ast::ast_operator op) -{ - switch (op) { - case ast::ast_operator::LOGICAL_AND: return ast::ast_operator::NULL_LOGICAL_AND; - case ast::ast_operator::LOGICAL_OR: return ast::ast_operator::NULL_LOGICAL_OR; - default: return op; - } -} - /** * @brief Returns whether a comparison operator can prune row groups via statistics * @@ -135,219 +118,129 @@ thrust::host_vector stats_columns_collector::get_stats_columns_mask() && } stats_expression_converter::stats_expression_converter( - ast::expression const& expr, - std::span output_dtypes, - cuda::stream_ref stream) - : stats_columns_collector{output_dtypes}, - _always_true_scalar{std::make_unique>(true, true, stream)}, - _always_true{std::make_unique(*_always_true_scalar)} + ast::expression const& expr, std::span output_dtypes) + : parquet_expression_simplifier{output_dtypes} { - _stats_cols_per_column = 3; - expr.accept(*this); + _stats_expr = simplify_expr(expr); } -void stats_expression_converter::push_non_null_guard(size_type col_index, - ast::expression const& stats_expr) +ast::expression const& stats_expression_converter::push_non_null_guard( + size_type col_index, ast::expression const& stats_expr) { using cudf::ast::ast_operator; - auto const& all_null = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column + 2}); + auto const& all_null = _tree.push(ast::column_reference{col_index * stats_cols_per_column + 2}); // Answering "not entirely null" takes all three of the column's states, so a plain NOT will not // do: its null state says the chunk holds both nulls and values, or that the writer recorded no // null count, and both of those answer this question true. NOT alone answers it null and hands an // unknown to a comparison that is in fact decisive. - auto const& not_all_null = _stats_expr.push( - ast::operation{ast_operator::NULL_LOGICAL_OR, - _stats_expr.push(ast::operation{ast_operator::IS_NULL, all_null}), - _stats_expr.push(ast::operation{ast_operator::NOT, all_null})}); + auto const& not_all_null = + _tree.push(ast::operation{ast_operator::NULL_LOGICAL_OR, + _tree.push(ast::operation{ast_operator::IS_NULL, all_null}), + _tree.push(ast::operation{ast_operator::NOT, all_null})}); // Null-aware so that the false this side pushes for an all-null chunk prunes it even though the // min and max it lacks leave `stats_expr` unknown. - _stats_expr.push(ast::operation{ast_operator::NULL_LOGICAL_AND, not_all_null, stats_expr}); + return _tree.push(ast::operation{ast_operator::NULL_LOGICAL_AND, not_all_null, stats_expr}); } -std::reference_wrapper stats_expression_converter::visit( - ast::operation const& expr) +simplified_expression_opt stats_expression_converter::simplify_comparison( + ast::ast_operator op, ast::column_reference const& col_ref, ast::literal const& literal_ref) { using cudf::ast::ast_operator; - auto const input_op = expr.get_operator(); - auto const operator_arity = cudf::ast::detail::ast_operator_arity(input_op); + auto const col_index = col_ref.get_column_index(); - // Unary operation - if (operator_arity == 1) { - auto const [kind, col_ref] = extract_unary_operand(expr); + // Some Parquet writers exclude `NaN`s from stats, so we can't reliably prune row groups for + // columns that may contain them. + if (not is_prunable_comparison(op, _output_dtypes[col_index])) { return std::nullopt; } - if (kind == operand_kind::COLUMN_REF) { - col_ref->accept(*this); + auto const& literal = _tree.push(literal_ref); - auto const col_index = col_ref->get_column_index(); - - // Evaluate IS_NULL unary operator - if (input_op == ast_operator::IS_NULL) { - CUDF_EXPECTS(std::cmp_equal(_stats_cols_per_column, 3), - "IS_NULL operator cannot be evaluated without nullability information column"); - auto const& vnull = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column + 2}); - _stats_expr.push(ast::operation{ast_operator::IDENTITY, vnull}); - return _stats_expr.back(); - } // For all other unary operators, push and return the `_always_true` expression - else { - _stats_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; - } - } else { - // `parquet_filter_normalizer::push_down_negation` deliberately does not complement ordering - // comparisons (NaN makes `NOT(a < b)` differ from `a >= b`), so `NOT(col op lit)` forms - // reach here. Stats transforms use different columns (vmin, vmax, is_null) for different - // operators such that NOT(col < val) is not equivalent to NOT(vmin < val) and instead is - // equivalent to vmax >= val. - if (input_op == ast_operator::NOT) { - auto const* child_operation = - dynamic_cast(&expr.get_operands().front().get()); - if (child_operation != nullptr) { - auto const child_op = child_operation->get_operator(); - - // If the child operator is IS_NULL, we can safely negate it without any modifications - if (child_op == ast_operator::IS_NULL) { - auto new_operands = visit_operands(expr.get_operands()); - if (&new_operands.front().get() == _always_true.get()) { - _stats_expr.push(ast::operation{ast_operator::IDENTITY, _stats_expr.back()}); - return *_always_true; - } else { - _stats_expr.push(ast::operation{ast_operator::NOT, new_operands.front()}); - return _stats_expr.back(); - } - } // Binary operation wrapped - else if (cudf::ast::detail::ast_operator_arity(child_op) == 2) { - // For NOT(col op lit) or NOT(lit op col), negate the operator if negatable and visit - // the negated operation directly. - auto const binary_operands = extract_binary_operands(*child_operation); - auto const lhs_kind = binary_operands.lhs_type; - auto const rhs_kind = binary_operands.rhs_type; - - // `col_ref` is only non-null for the `col op lit` form, so both checks below must - // stay inside this branch - if (lhs_kind == operand_kind::COLUMN_REF and rhs_kind == operand_kind::LITERAL) { - binary_operands.col_ref->accept(*this); - - // A comparison cannot be negated when the column may hold a `NaN` (floating points). - if (not cudf::is_floating_point( - _output_dtypes[binary_operands.col_ref->get_column_index()])) { - auto const negated_op = - transform_operator(child_operation->get_operator()); - if (negated_op.has_value()) { - auto const& child_operands = child_operation->get_operands(); - return visit( - ast::operation{*negated_op, child_operands.front(), child_operands.back()}); - } - } - } - } - } - } - // For all other unsafe NOT forms such as NOT(expr AND expr) as well as all other unary - // operators such as ABS(expr), visit operands and push _always_true - std::ignore = visit_operands(expr.get_operands()); - _stats_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; + switch (op) { + /* transform to stats conditions + col == val --> vmin <= val && vmax >= val + col != val --> vmin != vmax || vmax != val + col > val --> vmax > val + col < val --> vmin < val + col >= val --> vmax >= val + col <= val --> vmin <= val + */ + case ast_operator::EQUAL: { + auto const& vmin = _tree.push(ast::column_reference{col_index * stats_cols_per_column}); + auto const& vmax = _tree.push(ast::column_reference{col_index * stats_cols_per_column + 1}); + // The two halves are separately optional in the statistics, so they are combined null-aware + // to keep whichever one is present decisive. + auto const& in_range = _tree.push( + ast::operation{ast_operator::NULL_LOGICAL_AND, + _tree.push(ast::operation{ast_operator::GREATER_EQUAL, vmax, literal}), + _tree.push(ast::operation{ast_operator::LESS_EQUAL, vmin, literal})}); + // An all-null chunk has no min or max, so this range test is unknown there and would keep + // the chunk. The guard makes it prune instead. + return push_non_null_guard(col_index, in_range); + } + case ast_operator::NOT_EQUAL: { + auto const& vmin = _tree.push(ast::column_reference{col_index * stats_cols_per_column}); + auto const& vmax = _tree.push(ast::column_reference{col_index * stats_cols_per_column + 1}); + // Null-aware for the same reason as the range test above: either half can be the one the + // statistics carry. + auto const& outside_range = _tree.push( + ast::operation{ast_operator::NULL_LOGICAL_OR, + _tree.push(ast::operation{ast_operator::NOT_EQUAL, vmin, vmax}), + _tree.push(ast::operation{ast_operator::NOT_EQUAL, vmax, literal})}); + // A null does not satisfy `!=` either, and an all-null chunk has no min or max to make this + // test decisive, so the guard prunes it. + return push_non_null_guard(col_index, outside_range); } + case ast_operator::LESS: [[fallthrough]]; + case ast_operator::LESS_EQUAL: { + auto const& vmin = _tree.push(ast::column_reference{col_index * stats_cols_per_column}); + // An all-null chunk has no min, leaving this test unknown, so the guard prunes it. + return push_non_null_guard(col_index, _tree.push(ast::operation{op, vmin, literal})); + } + case ast_operator::GREATER: [[fallthrough]]; + case ast_operator::GREATER_EQUAL: { + auto const& vmax = _tree.push(ast::column_reference{col_index * stats_cols_per_column + 1}); + // An all-null chunk has no max, leaving this test unknown, so the guard prunes it. + return push_non_null_guard(col_index, _tree.push(ast::operation{op, vmax, literal})); + } + default: CUDF_UNREACHABLE("Non-prunable operator should not reach stats conversion"); } +} - // Binary operation - auto const [op, lhs_kind, rhs_kind, col_ref, literal_ptr] = extract_binary_operands(expr); - - // Push expressions for `col op lit` or `lit op col` forms - if (lhs_kind == operand_kind::COLUMN_REF and rhs_kind == operand_kind::LITERAL) { - col_ref->accept(*this); +simplified_expression_opt stats_expression_converter::simplify_unary_op( + ast::ast_operator op, ast::column_reference const& col_ref) +{ + using cudf::ast::ast_operator; - auto const col_index = col_ref->get_column_index(); + if (op != ast_operator::IS_NULL) { return std::nullopt; } + auto const& all_null = + _tree.push(ast::column_reference{col_ref.get_column_index() * stats_cols_per_column + 2}); + return _tree.push(ast::operation{ast_operator::IDENTITY, all_null}); +} - // Some Parquet writers exclude `NaN`s from stats, so we can't reliably prune row groups for - // columns that may contain them. - if (not is_prunable_comparison(op, _output_dtypes[col_index])) { - _stats_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; - } +simplified_expression_opt stats_expression_converter::simplify_negated_unary_op( + ast::ast_operator op, ast::column_reference const& col_ref) +{ + using cudf::ast::ast_operator; - // Push literal into the ast::tree - auto const& literal = _stats_expr.push(*literal_ptr); - - switch (op) { - /* transform to stats conditions - col == val --> vmin <= val && vmax >= val - col != val --> vmin != vmax || vmax != val - col > val --> vmax > val - col < val --> vmin < val - col >= val --> vmax >= val - col <= val --> vmin <= val - */ - case ast_operator::EQUAL: { - auto const& vmin = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column}); - auto const& vmax = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column + 1}); - // The two halves are separately optional in the statistics, so they are combined null-aware - // to keep whichever one is present decisive. - auto const& in_range = _stats_expr.push(ast::operation{ - ast::ast_operator::NULL_LOGICAL_AND, - _stats_expr.push(ast::operation{ast_operator::GREATER_EQUAL, vmax, literal}), - _stats_expr.push(ast::operation{ast_operator::LESS_EQUAL, vmin, literal})}); - // An all-null chunk has no min or max, so this range test is unknown there and would keep - // the chunk. The guard makes it prune instead. - push_non_null_guard(col_index, in_range); - break; - } - case ast_operator::NOT_EQUAL: { - auto const& vmin = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column}); - auto const& vmax = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column + 1}); - // Null-aware for the same reason as the range test above: either half can be the one the - // statistics carry. - auto const& outside_range = _stats_expr.push( - ast::operation{ast_operator::NULL_LOGICAL_OR, - _stats_expr.push(ast::operation{ast_operator::NOT_EQUAL, vmin, vmax}), - _stats_expr.push(ast::operation{ast_operator::NOT_EQUAL, vmax, literal})}); - // A null does not satisfy `!=` either, and an all-null chunk has no min or max to make this - // test decisive, so the guard prunes it. - push_non_null_guard(col_index, outside_range); - break; - } - case ast_operator::LESS: [[fallthrough]]; - case ast_operator::LESS_EQUAL: { - auto const& vmin = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column}); - // An all-null chunk has no min, leaving this test unknown, so the guard prunes it. - push_non_null_guard(col_index, _stats_expr.push(ast::operation{op, vmin, literal})); - break; - } - case ast_operator::GREATER: [[fallthrough]]; - case ast_operator::GREATER_EQUAL: { - auto const& vmax = - _stats_expr.push(ast::column_reference{col_index * _stats_cols_per_column + 1}); - // An all-null chunk has no max, leaving this test unknown, so the guard prunes it. - push_non_null_guard(col_index, _stats_expr.push(ast::operation{op, vmax, literal})); - break; - } - default: CUDF_UNREACHABLE("Non-prunable operator should not reach stats conversion"); - }; - } // Visit operands and push expression for `expr op expr` form - else if (lhs_kind == operand_kind::EXPRESSION and rhs_kind == operand_kind::EXPRESSION) { - auto new_operands = visit_operands(expr.get_operands()); - _stats_expr.push( - ast::operation{null_aware_operator(op), new_operands.front(), new_operands.back()}); - } // Push _always_true for `col op col`, `expr op col`, `expr op lit` forms - else { - _stats_expr.push(ast::operation{ast_operator::IDENTITY, *_always_true}); - return *_always_true; - } - return _stats_expr.back(); + if (op != ast_operator::IS_NULL) { return std::nullopt; } + auto const& all_null = + _tree.push(ast::column_reference{col_ref.get_column_index() * stats_cols_per_column + 2}); + return _tree.push(ast::operation{ast_operator::NOT, all_null}); } -std::reference_wrapper stats_expression_converter::get_stats_expr() const +simplified_expression_opt stats_expression_converter::simplify_negated_comparison( + ast::ast_operator op, ast::column_reference const& col_ref, ast::literal const& literal) { - return _stats_expr.back(); + // A comparison cannot be complemented when the column may hold a `NaN`: IEEE-754 makes every + // ordered comparison with a NaN false, so `NOT(col < val)` is true where `col >= val` is not. + if (cudf::is_floating_point(_output_dtypes[col_ref.get_column_index()])) { return std::nullopt; } + + auto const negated_op = transform_operator(op); + if (not negated_op.has_value()) { return std::nullopt; } + return simplify_comparison(*negated_op, col_ref, literal); } +simplified_expression_opt stats_expression_converter::get_stats_expr() const { return _stats_expr; } + } // namespace cudf::io::parquet::detail diff --git a/cpp/src/io/parquet/stats_filter_helpers.hpp b/cpp/src/io/parquet/stats_filter_helpers.hpp index 7183fcda2380..b7ebdd44507c 100644 --- a/cpp/src/io/parquet/stats_filter_helpers.hpp +++ b/cpp/src/io/parquet/stats_filter_helpers.hpp @@ -5,6 +5,7 @@ #pragma once +#include "expression_transform_helpers.hpp" #include "timestamp_utils.cuh" #include @@ -355,47 +356,69 @@ class stats_columns_collector : public ast::detail::expression_transformer { * statistics max value of a column is referenced by column_index*3+1 * statistics all_nulls value of a column is referenced by column_index*3+2 */ -class stats_expression_converter : public stats_columns_collector { +class stats_expression_converter : public parquet_expression_simplifier { public: stats_expression_converter(ast::expression const& expr, - std::span output_dtypes, - cuda::stream_ref stream); + std::span output_dtypes); - // Bring all overrides of `visit` from stats_columns_collector into scope - using stats_columns_collector::visit; + /** + * @brief Returns the AST to apply on column chunk statistics + * + * @return The statistics expression, or std::nullopt if no row group can be pruned + */ + [[nodiscard]] simplified_expression_opt get_stats_expr() const; + protected: /** - * @copydoc ast::detail::expression_transformer::visit(ast::operation const& ) + * @copydoc parquet_expression_simplifier::simplify_comparison */ - std::reference_wrapper visit(ast::operation const& expr) override; + [[nodiscard]] simplified_expression_opt simplify_comparison(ast::ast_operator op, + ast::column_reference const& col_ref, + ast::literal const& literal) override; /** - * @brief Returns the AST to apply on Column chunk statistics. + * @copydoc parquet_expression_simplifier::simplify_unary_op * - * @return AST operation expression + * `IS_NULL` is the only unary operation statistics can evaluate via the all-nulls column. */ - [[nodiscard]] std::reference_wrapper get_stats_expr() const; + [[nodiscard]] simplified_expression_opt simplify_unary_op( + ast::ast_operator op, ast::column_reference const& col_ref) override; /** - * @brief Delete stats columns mask getter as it's not needed in the derived class + * @copydoc parquet_expression_simplifier::simplify_negated_unary_op + * + * The three-state all-nulls value can be safely negated for `NOT(IS_NULL(col))`. + */ + [[nodiscard]] simplified_expression_opt simplify_negated_unary_op( + ast::ast_operator op, ast::column_reference const& col_ref) override; + + /** + * @copydoc parquet_expression_simplifier::simplify_negated_comparison + * + * `NOT(col < val)` is converted to `col >= val` instead of negating `vmin < val`. */ - thrust::host_vector get_stats_columns_mask() && = delete; + [[nodiscard]] simplified_expression_opt simplify_negated_comparison( + ast::ast_operator op, + ast::column_reference const& col_ref, + ast::literal const& literal) override; private: + /// Number of statistics columns per input table column: min, max and all-nulls + static constexpr size_type stats_cols_per_column = 3; + /** - * @brief Push `not_all_null AND stats_expr` for a column, so that a chunk holding nothing but + * @brief Returns `not_all_null AND stats_expr` for a column, so that a chunk holding nothing but * nulls is pruned by a predicate needing a non-null value to match, rather than kept because its * absent min and max leave the comparison null * * @param col_index Index of the column in the input table * @param stats_expr Statistics expression to guard, already pushed onto the tree + * @return The guarded statistics expression */ - void push_non_null_guard(size_type col_index, ast::expression const& stats_expr); + [[nodiscard]] ast::expression const& push_non_null_guard(size_type col_index, + ast::expression const& stats_expr); - ast::tree _stats_expr; - cudf::size_type _stats_cols_per_column; - std::unique_ptr> _always_true_scalar; - std::unique_ptr _always_true; + simplified_expression_opt _stats_expr; }; } // namespace cudf::io::parquet::detail diff --git a/cpp/tests/io/parquet_reader_test.cpp b/cpp/tests/io/parquet_reader_test.cpp index 8f61d2f2c07e..73b8ab0ae246 100644 --- a/cpp/tests/io/parquet_reader_test.cpp +++ b/cpp/tests/io/parquet_reader_test.cpp @@ -2303,9 +2303,8 @@ TEST_F(ParquetReaderTest, ExtendedFilterExpressions) 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); - // Stats filter cannot prune row groups - EXPECT_EQ(result.metadata.num_row_groups_after_stats_filter.value(), - result.metadata.num_input_row_groups); + // Stats filter cannot prune `false`, but still prunes on 50 > col_a + EXPECT_EQ(result.metadata.num_row_groups_after_stats_filter.value(), 1); } // Filter: NOT(col_a NULL_EQUAL 10) From 8840ea34685397f79045c68229126440bdcc1b91 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:55:46 +0000 Subject: [PATCH 2/4] Add a gtest --- cpp/tests/io/parquet_reader_test.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cpp/tests/io/parquet_reader_test.cpp b/cpp/tests/io/parquet_reader_test.cpp index 73b8ab0ae246..1e2df3ef2195 100644 --- a/cpp/tests/io/parquet_reader_test.cpp +++ b/cpp/tests/io/parquet_reader_test.cpp @@ -2307,6 +2307,28 @@ TEST_F(ParquetReaderTest, ExtendedFilterExpressions) EXPECT_EQ(result.metadata.num_row_groups_after_stats_filter.value(), 1); } + // Filter: (col_a == 1) == (col_b == 2) + { + auto literal_1_value = cudf::numeric_scalar(1); + auto literal_1 = cudf::ast::literal(literal_1_value); + auto literal_2_value = cudf::numeric_scalar(2); + auto literal_2 = cudf::ast::literal(literal_2_value); + auto a_eq_1 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col_ref_a, literal_1); + auto b_eq_2 = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col_ref_b, literal_2); + auto filter = cudf::ast::operation(cudf::ast::ast_operator::EQUAL, a_eq_1, b_eq_2); + + auto predicate = cudf::compute_column(written_table, filter); + auto expected = cudf::apply_retention_mask(written_table, *predicate); + + cudf::io::parquet_reader_options 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); + // Comparing two per-row expressions cannot be evaluated using their independent summaries + EXPECT_EQ(result.metadata.num_row_groups_after_stats_filter.value(), + result.metadata.num_input_row_groups); + } + // Filter: NOT(col_a NULL_EQUAL 10) { auto literal_10_value = cudf::numeric_scalar(10); From fd0fc26471355c285ff7e86b321c6531bfdeb02a Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:38:44 +0000 Subject: [PATCH 3/4] Use spans instead of host_spans --- cpp/src/io/parquet/bloom_filter_reader.cu | 14 ++++++++------ .../parquet/experimental/dictionary_page_filter.cu | 9 ++++----- .../io/parquet/experimental/page_index_filter.cu | 4 ++-- .../experimental/page_index_filter_utils.cu | 2 +- .../experimental/page_index_filter_utils.hpp | 2 +- .../io/parquet/expression_transform_helpers.hpp | 7 ++++--- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index 6119c24e8904..c84ccec290be 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -35,6 +35,7 @@ #include #include #include +#include #include namespace cudf::io::parquet::detail { @@ -182,10 +183,9 @@ class bloom_filter_expression_converter : public parquet_expression_simplifier { public: bloom_filter_expression_converter( ast::expression const& expr, - cudf::host_span output_dtypes, - cudf::host_span const> equality_literals) - : parquet_expression_simplifier{std::span{output_dtypes.data(), output_dtypes.size()}}, - _equality_literals{equality_literals} + std::span output_dtypes, + std::span const> equality_literals) + : parquet_expression_simplifier{output_dtypes}, _equality_literals{equality_literals} { // Compute and store columns literals offsets _col_literals_offsets.reserve(static_cast(_output_dtypes.size()) + 1); @@ -252,7 +252,7 @@ class bloom_filter_expression_converter : public parquet_expression_simplifier { private: std::vector _col_literals_offsets; - cudf::host_span const> _equality_literals; + std::span const> _equality_literals; simplified_expression_opt _bloom_filter_expr; }; @@ -379,7 +379,9 @@ std::optional>> aggregate_reader_metadata::ap // Convert AST to BloomfilterAST expression with reference to bloom filter membership // in above `bloom_filter_membership_table` bloom_filter_expression_converter bloom_filter_expr_converter{ - filter.get(), output_dtypes, {literals}}; + filter.get(), + std::span{output_dtypes.data(), output_dtypes.size()}, + std::span{literals.data(), literals.size()}}; // Return early if bloom filters cannot prune any row groups using the filter auto const bloom_filter_expr = bloom_filter_expr_converter.get_bloom_filter_expr(); diff --git a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu index 6b117fee7f41..8792bb01e8e8 100644 --- a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu +++ b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu @@ -31,6 +31,7 @@ #include #include +#include namespace cudf::io::parquet::experimental::detail { @@ -1324,7 +1325,7 @@ class dictionary_expression_converter : public parquet_expression_simplifier { public: dictionary_expression_converter(ast::expression const& expr, std::span output_dtypes, - cudf::host_span const> literals) + std::span const> literals) : parquet_expression_simplifier{output_dtypes}, _literals{literals} { // Compute and store columns literals offsets @@ -1391,7 +1392,7 @@ class dictionary_expression_converter : public parquet_expression_simplifier { private: std::vector _col_literals_offsets; - cudf::host_span const> _literals; + std::span const> _literals; simplified_expression_opt _dictionary_expr; }; @@ -1415,9 +1416,7 @@ aggregate_reader_metadata::apply_dictionary_filter( // Convert AST to DictionaryAST expression with reference to dictionary membership // in above `dictionary_membership_table` dictionary_expression_converter dictionary_expr_converter{ - filter.get(), - output_dtypes, - cudf::host_span const>{literals.data(), literals.size()}}; + filter.get(), output_dtypes, literals}; // Dictionary membership cannot filter anything in the filter, all row groups survive auto const dictionary_expr = dictionary_expr_converter.get_dictionary_expr(); diff --git a/cpp/src/io/parquet/experimental/page_index_filter.cu b/cpp/src/io/parquet/experimental/page_index_filter.cu index a1d7e8bd96ff..9ea87ed53f22 100644 --- a/cpp/src/io/parquet/experimental/page_index_filter.cu +++ b/cpp/src/io/parquet/experimental/page_index_filter.cu @@ -58,7 +58,7 @@ namespace { */ struct page_stats_caster : public stats_caster_base { cudf::size_type total_rows; - cudf::host_span per_file_metadata; + std::span per_file_metadata; std::span const> row_group_indices; bool const has_is_null_operator; @@ -541,7 +541,7 @@ struct page_stats_caster : public stats_caster_base { */ struct page_stats_to_row_mask_converter : public page_stats_caster { page_stats_to_row_mask_converter(cudf::size_type total_rows, - cudf::host_span per_file_metadata, + std::span per_file_metadata, std::span const> row_group_indices, bool has_is_null_operator) : page_stats_caster{.total_rows = total_rows, diff --git a/cpp/src/io/parquet/experimental/page_index_filter_utils.cu b/cpp/src/io/parquet/experimental/page_index_filter_utils.cu index 07c81c787afe..6e017fbd3e64 100644 --- a/cpp/src/io/parquet/experimental/page_index_filter_utils.cu +++ b/cpp/src/io/parquet/experimental/page_index_filter_utils.cu @@ -92,7 +92,7 @@ compute_page_row_offsets_and_colchunk_page_offsets( } std::pair, size_type> compute_page_row_offsets( - cudf::host_span per_file_metadata, + std::span per_file_metadata, std::span const> row_group_indices, cudf::size_type schema_idx) { diff --git a/cpp/src/io/parquet/experimental/page_index_filter_utils.hpp b/cpp/src/io/parquet/experimental/page_index_filter_utils.hpp index 264510ff48fd..78eba2fb1947 100644 --- a/cpp/src/io/parquet/experimental/page_index_filter_utils.hpp +++ b/cpp/src/io/parquet/experimental/page_index_filter_utils.hpp @@ -51,7 +51,7 @@ compute_page_row_offsets_and_colchunk_page_offsets( * column */ [[nodiscard]] std::pair, size_type> compute_page_row_offsets( - cudf::host_span per_file_metadata, + std::span per_file_metadata, std::span const> row_group_indices, size_type schema_idx); diff --git a/cpp/src/io/parquet/expression_transform_helpers.hpp b/cpp/src/io/parquet/expression_transform_helpers.hpp index 2b7b67ec4e11..aadecf2b96d1 100644 --- a/cpp/src/io/parquet/expression_transform_helpers.hpp +++ b/cpp/src/io/parquet/expression_transform_helpers.hpp @@ -357,14 +357,15 @@ using simplified_expression_opt = std::optional output_dtypes); ~parquet_expression_simplifier() = default; - parquet_expression_simplifier(parquet_expression_simplifier const&) = delete; - parquet_expression_simplifier& operator=(parquet_expression_simplifier const&) = delete; - /** * @brief Simplifies a `col op lit` comparison * From 6ee03088f6509b0591d881e6dfec4e471407a8f1 Mon Sep 17 00:00:00 2001 From: Muhammad Haseeb <14217455+mhaseeb123@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:48:03 +0000 Subject: [PATCH 4/4] Apply suggestions from coderabbit --- cpp/src/io/parquet/bloom_filter_reader.cu | 7 +++--- .../experimental/dictionary_page_filter.cu | 25 +++++++++++-------- .../experimental/hybrid_scan_filters_test.cpp | 10 +++----- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index c84ccec290be..8885e53d71b9 100644 --- a/cpp/src/io/parquet/bloom_filter_reader.cu +++ b/cpp/src/io/parquet/bloom_filter_reader.cu @@ -181,10 +181,9 @@ struct bloom_filter_caster { */ class bloom_filter_expression_converter : public parquet_expression_simplifier { public: - bloom_filter_expression_converter( - ast::expression const& expr, - std::span output_dtypes, - std::span const> equality_literals) + bloom_filter_expression_converter(ast::expression const& expr, + std::span output_dtypes, + std::span const> equality_literals) : parquet_expression_simplifier{output_dtypes}, _equality_literals{equality_literals} { // Compute and store columns literals offsets diff --git a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu index 8792bb01e8e8..522cc0841b1b 100644 --- a/cpp/src/io/parquet/experimental/dictionary_page_filter.cu +++ b/cpp/src/io/parquet/experimental/dictionary_page_filter.cu @@ -31,6 +31,7 @@ #include #include +#include #include namespace cudf::io::parquet::experimental::detail { @@ -1325,8 +1326,9 @@ class dictionary_expression_converter : public parquet_expression_simplifier { public: dictionary_expression_converter(ast::expression const& expr, std::span output_dtypes, - std::span const> literals) - : parquet_expression_simplifier{output_dtypes}, _literals{literals} + std::span const> literals, + std::span const> operators) + : parquet_expression_simplifier{output_dtypes}, _literals{literals}, _operators{operators} { // Compute and store columns literals offsets _col_literals_offsets.reserve(static_cast(_output_dtypes.size()) + 1); @@ -1366,16 +1368,18 @@ class dictionary_expression_converter : public parquet_expression_simplifier { if (op != ast_operator::EQUAL and op != ast_operator::NOT_EQUAL) { return std::nullopt; } - auto const col_idx = col_ref.get_column_index(); - auto const& equality_literals = _literals[col_idx]; - auto const literal_iter = - std::find(equality_literals.cbegin(), equality_literals.cend(), &literal); - CUDF_EXPECTS(literal_iter != equality_literals.end(), + auto const col_idx = col_ref.get_column_index(); + auto const& equality_literals = _literals[col_idx]; + auto const& equality_operators = _operators[col_idx]; + auto const literal_indices = std::views::iota(std::size_t{0}, equality_literals.size()); + auto const literal_iter = std::ranges::find_if(literal_indices, [&](auto idx) { + return equality_literals[idx] == &literal and equality_operators[idx] == op; + }); + CUDF_EXPECTS(literal_iter != literal_indices.end(), "Dictionary expression converter encountered an unexpected literal"); auto const col_literal_offset = - _col_literals_offsets[col_idx] + - static_cast(std::distance(equality_literals.cbegin(), literal_iter)); + _col_literals_offsets[col_idx] + static_cast(*literal_iter); auto const& value = _tree.push(ast::column_reference{col_literal_offset}); if (op == ast_operator::NOT_EQUAL) { @@ -1393,6 +1397,7 @@ class dictionary_expression_converter : public parquet_expression_simplifier { private: std::vector _col_literals_offsets; std::span const> _literals; + std::span const> _operators; simplified_expression_opt _dictionary_expr; }; @@ -1416,7 +1421,7 @@ aggregate_reader_metadata::apply_dictionary_filter( // Convert AST to DictionaryAST expression with reference to dictionary membership // in above `dictionary_membership_table` dictionary_expression_converter dictionary_expr_converter{ - filter.get(), output_dtypes, literals}; + filter.get(), output_dtypes, literals, operators}; // Dictionary membership cannot filter anything in the filter, all row groups survive auto const dictionary_expr = dictionary_expr_converter.get_dictionary_expr(); diff --git a/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp b/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp index bda5374ba3b2..d0d645007910 100644 --- a/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp +++ b/cpp/tests/io/experimental/hybrid_scan_filters_test.cpp @@ -1145,15 +1145,13 @@ TEST_F(HybridScanFiltersTest, FilterRowGroupsWithDictionary) } { - // Filtering - table[0] != 50 and table[0] == 50 - auto uint_literal_value = cudf::numeric_scalar(50, true, stream); - auto uint_literal_value2 = cudf::numeric_scalar(50, true, stream); - auto uint_literal = cudf::ast::literal(uint_literal_value); - auto uint_literal2 = cudf::ast::literal(uint_literal_value2); + // Filtering - table[0] != 50 and table[0] == 50, reusing the same literal expression + auto uint_literal_value = cudf::numeric_scalar(50, true, stream); + auto uint_literal = cudf::ast::literal(uint_literal_value); auto uint_filter_expression = cudf::ast::operation(cudf::ast::ast_operator::NOT_EQUAL, col0_ref, uint_literal); auto uint_filter_expression2 = - cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col0_ref, uint_literal2); + cudf::ast::operation(cudf::ast::ast_operator::EQUAL, col0_ref, uint_literal); auto filter_expression = cudf::ast::operation( cudf::ast::ast_operator::LOGICAL_AND, uint_filter_expression, uint_filter_expression2);