feat: expose UDF field access and input requirements for Parquet pruning - #25013
feat: expose UDF field access and input requirements for Parquet pruning#25013peterxcli wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25013 +/- ##
==========================================
+ Coverage 81.72% 81.74% +0.01%
==========================================
Files 1127 1127
Lines 416519 416944 +425
Branches 416519 416944 +425
==========================================
+ Hits 340401 340821 +420
+ Misses 56115 56108 -7
- Partials 20003 20015 +12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
de7d5a3 to
1180ae3
Compare
|
I validated this API in peterxcli/datafusion-variant#1. Variant projection, filtering and aggregate input pruning now work while preserving encoded fallback. Warm-cache median execution time on a wide, partially shredded dataset (1,048,576 rows; 12 trials per case; planning excluded):
Projection/filter compare the original downstream implementation with the input-field capability; their disabled controls use the new dependencies with that capability off. The aggregate is a separate comparison against the previous downstream PR head, with matching dependencies and the capability enabled throughout; its control disables expression placement. Moving the accessor into the scan projection lets the aggregate prune unused inputs. Reader bytes fall about 80%, despite retaining the encoded root value. All 405 original and 249 follow-up executions passed result checks, including encoded-fallback cases. Raw-input and follow-up projection/filter controls remain approximately unchanged. These gains are specific to this wide synthetic workload. peterxcli/datafusion#2 separately builds on |
| && let Some(requirements) = function.required_input_fields(self.file_schema) | ||
| && !requirements.is_empty() | ||
| { | ||
| for (index, argument) in function.args().iter().enumerate() { |
There was a problem hiding this comment.
Thanks @peterxcli , here is a suggestion:
required_input_fields turns off the nested-column pushdown gate
projection_read_plan.rs:443-480. The requirements branch returns TreeNodeRecursion::Jump
for every argument it claims, which skips check_single_column → handle_nested_type. That's
the check that sets non_primitive_columns for List/Map columns unless allow_list_columns
(i.e. supports_list_predicates, the verified array_has* / IS NULL allow-list) is true.
The accessor branch immediately above is guarded — !return_type.is_nested() || self.is_nested_type_supported(&return_type) — but this one has no type check at all, so
prevents_pushdown() stays false.
Confirmed with a scratch probe against PushdownChecker::new(&schema, /*allow_list_columns=*/ false, false):
// schema: s: List<Int32>
fn required_input_fields(&self, _: ReturnFieldArgs) -> Option<Vec<InputFieldRequirement>> {
Some(vec![InputFieldRequirement { arg_index: 0, field_paths: vec![vec![]] }])
}
// => prevents_pushdown() == false (expected: true)Same result for schema: s: Struct<events: List<Int32>> with field_paths: vec![vec!["events".into()]],
and the same hole applies to Map. So any downstream UDF can opt itself past the gate by
declaring a requirement that prunes nothing — which contradicts this PR's own contract text:
"does not ... authorize moving the function across arbitrary operators".
Suggested fix — resolve each declared path's leaf type and apply the existing policy before
taking the shortcut, falling back to normal traversal otherwise:
if let Some(function) = node.downcast_ref::<ScalarFunctionExpr>()
&& let Some(requirements) = function.required_input_fields(self.file_schema)
&& !requirements.is_empty()
+ && requirements.iter().all(|requirement| {
+ // Reading a nested column into the row filter follows the same policy
+ // as an accessor: Struct subtrees are fine, other nested types only
+ // when the predicate set supports them. A declaration must not widen it.
+ function.args()[requirement.arg_index]
+ .return_field(self.file_schema)
+ .is_ok_and(|field| {
+ requirement.field_paths.iter().all(|path| {
+ resolve_leaf_type(field.data_type(), path).is_some_and(|leaf| {
+ matches!(leaf, DataType::Struct(_))
+ || !DataType::is_nested(leaf)
+ || self.is_nested_type_supported(leaf)
+ })
+ })
+ })
+ })
{(resolve_leaf_type being the same name-resolving Struct-only walk already inlined in the
accessor branch — worth factoring out, since it now has three copies.)
A regression test in the same style as custom_struct_accessor_does_not_prune_map_entries
asserting prevents_pushdown() for a List-bearing requirement would lock this down.
Which issue does this PR close?
Closes #21306.
Rationale for this change
A custom accessor can miss Parquet column pruning and filter pushdown even when it reads only one field:
The affected optimization paths recognize the built-in
GetFieldFunc. For an arbitrary UDF, they cannot assume that the other fields are unnecessary: the function could inspect them, validate them or change null handling.The existing
placement()method describes where a function should execute. It does not describe which nested input fields it requires. These are separate facts needed by the optimizer.There are also two distinct field-related guarantees. An ordinary accessor can promise exact field extraction. A function such as downstream
variant_getmay instead need several physical fields, decode fallback values and convert its result. Treating that computation as exact field extraction would be incorrect.This draft exposes both contracts. DataFusion manages generic field paths and Parquet planning. Downstream UDFs interpret their own layouts and declare their requirements; Variant path, shredding, decoding and fallback logic stay in
datafusion-variant.What changes are included in this PR?
struct_field_access()for exact extraction, describing the source argument and literal field path. Implement it forGetFieldFuncand use it in Parquet planning and schema adaptation.required_input_fields(ReturnFieldArgs)returningInputFieldRequirement { arg_index, field_paths }. It permits pruning fields while retaining the original UDF computation. Forward both hooks throughScalarUDFand aliases.The input-requirements contract promises identical values, output field and errors after pruning, including when requirements are combined with other consumers. Required validation fields, metadata and encoded fallback values must be included. Selected fields and their ancestors retain metadata and validity. Paths traverse structs and may select entire nested subtrees; invalid declarations retain full inputs.
Input requirements do not establish field equivalence, output statistics, or permission for arbitrary filter movement. Exact extraction remains a stronger, separate promise. Existing implementations and FFI wrappers retain the default fallback.
What is the testing strategy for this PR?
Custom accessor tests cover reversed arguments, aliases, chained paths, literal dots, null parents and children, schema reordering, missing fields, integer widening, explicit cast errors and Map fallback. Leaf-selection assertions and decoder metrics demonstrate pruning, with capability-disabled and pushdown-disabled controls.
Additional generic-requirement tests cover a computed UDF requiring multiple fields and another argument, unions with other consumers, stale column indices in computed arguments, invalid declarations and preservation of cast errors.
Downstream validation in peterxcli/datafusion-variant#1 exercises
variant_getandvariant_get_fieldwith these APIs, including encoded fallback and null semantics. Literal accessor placement also exposes aggregate arguments to scan projection pruning. The integration tests and controlled benchmarks demonstrate input pruning for projection, filters and aggregates without Variant-specific logic in DataFusion.Are there any user-facing changes?
Custom Rust UDFs can opt into Parquet input pruning and decoder-filter evaluation through additive APIs. DataFusion gains no Variant-specific dependencies or semantics. The methods document their correctness contracts; unsupported calls keep conservative behavior.