diff --git a/cpp/src/io/parquet/bloom_filter_reader.cu b/cpp/src/io/parquet/bloom_filter_reader.cu index d758c6b71590..8885e53d71b9 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 { @@ -178,20 +179,13 @@ 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)} + 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} { - // 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 +198,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; + std::span const> _equality_literals; + simplified_expression_opt _bloom_filter_expr; }; } // namespace @@ -413,6 +375,17 @@ 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(), + 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(); + 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 +430,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..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,8 @@ #include #include +#include +#include namespace cudf::io::parquet::experimental::detail { @@ -1313,23 +1315,21 @@ 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, + std::span const> literals, + std::span const> operators) + : parquet_expression_simplifier{output_dtypes}, _literals{literals}, _operators{operators} { - // 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 +1343,62 @@ 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); + if (op != ast_operator::EQUAL and op != ast_operator::NOT_EQUAL) { return std::nullopt; } - // 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; + 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(*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; + std::span const> _literals; + std::span const> _operators; + simplified_expression_opt _dictionary_expr; }; } // namespace @@ -1454,6 +1416,17 @@ 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, literals, operators}; + + // 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 +1492,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..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, @@ -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/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.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..aadecf2b96d1 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,125 @@ 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 { + public: + parquet_expression_simplifier(parquet_expression_simplifier const&) = delete; + parquet_expression_simplifier& operator=(parquet_expression_simplifier const&) = delete; + + protected: + explicit parquet_expression_simplifier(std::span output_dtypes); + + ~parquet_expression_simplifier() = default; + + /** + * @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/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); diff --git a/cpp/tests/io/parquet_reader_test.cpp b/cpp/tests/io/parquet_reader_test.cpp index 6feb86c72382..57fe57d46e75 100644 --- a/cpp/tests/io/parquet_reader_test.cpp +++ b/cpp/tests/io/parquet_reader_test.cpp @@ -2303,7 +2303,28 @@ 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 + // 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: (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); }