Repository navigation
feat(parquet): add statistics-based row group filtering - #956
YinZheng-Sun wants to merge 16 commits into
Conversation
wgtmac
left a comment
There was a problem hiding this comment.
I haven't finished reviewing it yet, just post my finding so far. Please let me know what you think.
Handle constant expressions with tri-state binding detection and reject partially bound expressions. Bind unbound filters against the reader projection and pass case sensitivity explicitly from scan options. Disable floating-point bounds pruning while preserving null-count checks. Add regression coverage for finite bounds that omit NaNs, constant residuals, case sensitivity, and caller-provided read schemas.
|
Sorry for the late reply, and thanks for the detailed review. @wgtmac |
|
I noticed a difference in how Iceberg-CPP and Java handle null comparisons. Literal::operator<=> currently returns std::partial_ordering::unordered when either operand is null. Therefore, when key is null: key < 100 // false However, RewriteNot rewrites NOT(key < 100) into key >= 100. These expressions are not equivalent under the current C++ semantics. Using the rewritten expression for row-group pruning can discard rows that the original expression would match in Evaluator. |
There was a problem hiding this comment.
I think you're right that this indeed is a problem and different impls are not consistent:
| impl | x < v on null |
NOT |
|---|---|---|
| Java | true (Literals nulls-first) |
rewritten in every pruning evaluator |
| Go | true (nullsFirstCmp) |
rewritten in every pruning evaluator (RewriteNotExpr) |
| Rust | false | rewritten once in the scan; evaluator errors on a leftover Not |
| Python | false | metrics evaluator raises on a leftover Not |
Perhaps in the short term (in this PR), we can keep RewriteNot in the pruning filter and make the evaluator use the same rule. Apply RewriteNot when building the Evaluator, or error on a leftover Not like iceberg-rust. Then not(key < 100) is key >= 100 for every value, so pruning keeps full strength on nullable columns.
In the long run, we can give the evaluator three states for null comparisons (null-first, null-last, unknown), so not(unknown) stays unknown. Pruning stays sound and semantics match SQL exactly.
WDYT? @YinZheng-Sun
Thanks for the suggestion. I will apply the short-term approach firstly: Evaluator::Make() applies RewriteNot, so row evaluation and row-group pruning use the same normalized predicates. |
Rewrite NOT after binding so nullable range comparisons use the same normalized predicates as row-group pruning. Add regression coverage for null values, comparison boundaries, and pruning nullable row groups.
| }; | ||
|
|
||
| class ICEBERG_EXPORT IsBoundVisitor : public ExpressionVisitor<bool> { | ||
| class ICEBERG_EXPORT IsBoundVisitor : public ExpressionVisitor<std::optional<bool>> { |
There was a problem hiding this comment.
why this change?
I think whether an expression is bounded or not should be deterministic
There was a problem hiding this comment.
The optional represents an internal traversal state, not uncertainty about whether an expression is bound. nullopt means that the subtree contains no references, as with True or False.
Constants need to be neutral when combining binding states: And(True, unbound) should be unbound, while And(True, bound) should be bound. Mixed bound and unbound predicates are still rejected. The public IsBound() API continues to return a definite boolean.
This follows Java’s IsBoundVisitor.
There was a problem hiding this comment.
This is a good point. Let's make a separate pr of this fix? It's not part of the row group metrics filter but rather a prerequisite.
Also BTW, I think we should hide the IsBoundVisitor from the public contract, otherwise, we would be more careful about the iceberg_export APIs.
|
|
||
| Result<bool> AlwaysFalse() override { return kRowsCannotMatch; } | ||
|
|
||
| Result<bool> Not(bool) override { return kRowsMightMatch; } |
There was a problem hiding this comment.
why is this?
it should be Not(bool result) override { return !result }?
If this is on purpose, then at least a comment should be added to justify.
There was a problem hiding this comment.
I agree that we should add a comment for this workaround.
There was a problem hiding this comment.
I think !result is a latent correctness bug.
The contract of this visitor is: return ROWS_CANNOT_MATCH only when it is proven that
no row in the group matches. ROWS_MIGHT_MATCH means "unknown", and negating "unknown"
is still "unknown", not "cannot match". So !result breaks the contract.
Example: stats x in [5, 15], filter NOT(x < 10). The child returns might-match, so
!result returns cannot-match and the group is skipped, although rows with
x in [10, 15] match. That is silent data loss.
| if (!ref || !ref->type()->is_primitive() || | ||
| MetadataColumns::IsMetadataColumn(ref->field_id()) || | ||
| MetadataColumns::IsRowLineageColumn(ref->field_id())) { | ||
| return nullptr; | ||
| } |
There was a problem hiding this comment.
this seems unnecessary? if the ref column is nested, or meta column, then it should not be presented in the field_id_column_idx_ mapping.
There was a problem hiding this comment.
That is true for complex-type containers and virtual metadata columns. However, nested primitive leaves do appear in the map. Physically stored _row_id and _last_updated_sequence_number can also appear there, since the map currently includes every physical leaf with a valid field ID.
Row-lineage nulls may be replaced with inherited values during reading, so their physical statistics do not necessarily describe the returned values. The null-reference guard is also needed for transform terms.
There is a redundant check here, though: IsMetadataColumn() already includes the row-lineage IDs. I’ll remove the separate IsRowLineageColumn() check.
| if (!lower || lower->type()->type_id() != TypeId::kString) { | ||
| return kRowsMightMatch; | ||
| } |
There was a problem hiding this comment.
hmmm. this is strange. I think you are mixing validation and metrics evaluation here. I'm not sure it is good way to add such check here.
There was a problem hiding this comment.
OK, I will remove it.
| return kRowsMightMatch; | ||
| } | ||
| const auto& prefix = std::get<std::string>(value.value()); | ||
| if (std::get<std::string>(lower->value()).compare(0, prefix.size(), prefix) > 0) { |
There was a problem hiding this comment.
- I'm not sure we can use std::string to compare string columns directly. Per iceberg's spec String column must be encoded as UTF-8 bytes. we may compare unsigned utf-8 bytes directly. cc @wgtmac if you have more insights.
There was a problem hiding this comment.
std::string::compare uses std::char_traits, which the standard specifies
compares as unsigned char, so this is an unsigned byte-wise comparison and matches
UTF-8 code point order.
| } | ||
|
|
||
| template <typename Comparator> | ||
| bool VisitInequality(const std::shared_ptr<BoundReference>& ref, const Literal& value, |
There was a problem hiding this comment.
nit: VisitInequality is not a good name. There's no visit logic in the method.
There was a problem hiding this comment.
The name follows iceberg-rust’s visit_inequality(), which serves the same purpose.
| // Share a reader across adjacent row groups so Arrow can coalesce pre-buffered | ||
| // reads. Stop at gaps to keep physical row positions contiguous within batches. | ||
| while (end < context_->row_groups_.size() && | ||
| context_->row_groups_[end].index == context_->row_groups_[end - 1].index + 1) { | ||
| row_groups.push_back(context_->row_groups_[end].index); | ||
| ++end; | ||
| } |
There was a problem hiding this comment.
why this change?
Seems like unrelated change to the metrics filter?
There was a problem hiding this comment.
This change is needed when integrating row-group pruning with physical row-position tracking. Previously, the reader passed all selected row groups to one GetRecordBatchReader() call. Pruning can introduce gaps, so the new implementation splits the reader at those gaps to prevent a batch from spanning discontinuous file positions.
Keeping adjacent groups together preserves the existing multi-row-group batching and pre-buffering behavior, instead of creating a reader for every group. I’ll clarify this motivation in the comment.
| context_->record_batch_reader_, | ||
| reader_->GetRecordBatchReader(row_groups, SelectedColumnIndices(projection_))); | ||
| context_->next_row_group_ = end; | ||
| metadata_context_.next_file_pos = context_->row_groups_[begin].first_row; |
There was a problem hiding this comment.
hmmm. It looks like that how iceberg-cpp currently support _pos metadata column is problematic. It tracks next_file_pos in MetadataColumnContext and updates/incrs per batch. This might work for parquet reader without data skipping.. Once row group filtering, and later page skipping is kicked in, it would be hard to track that correctly. I think we should follow how parquet-rs: apache/arrow-rs#8715 or iceberg-java's approach(org.apache.iceberg.parquet.ParquetValueReaders.PositionReader) to support _pos metadata column. WDYT @wgtmac
There was a problem hiding this comment.
Yes, this is a new issue if data skipping is supported. I think either of your proposed approach is fine but we would wait longer if we want to fix it in Arrow C++.
There was a problem hiding this comment.
Let me see if whether we can add the support in iceberg-cpp first, then we can contribute back to arrow c++.
7e379e7 to
aca3b9e
Compare
|
|
||
| namespace { | ||
|
|
||
| Result<std::optional<bool>> CombineResults(const std::optional<bool>& left_result, |
There was a problem hiding this comment.
This method should be scoped to IsBoundVisitor? CombineBoundResults or make it a private helper method of IsBoundVisitor?
|
I did a quick pass on the new code and responses. I think most of them are addressed and will do a more detailed round later. |
What
Adds
ParquetMetricsRowGroupFilterto evaluate Iceberg predicates against Parquet footer statistics before reading row groups.Adds tests covering split intersection, non-contiguous row groups, physical row positions, unprojected filter columns, schema evolution, missing statistics, predicate binding, and delete handling.
Why
The Parquet reader previously selected row groups only by file split. Filter predicates could not eliminate row groups whose footer statistics ruled out matching rows.
Behavior change
read.parquet.row-group-filter.enabled.Testing
Built and ran
parquet_test,data_test,avro_test, andexpression_testlocally during implementation. Rebuilt and reranparquet_testanddata_testafter the binding and field-mapping refactors; both passed.The
INregression covers 201 input literals that deduplicate to 200 values, ensuring the limit applies to the bound set.