Conversation
… a projection column Several rules replace a column reference with the expression that the projection below defines for it. Each rule had its own guard against the duplication that this can cause. Add a crate-private `ProjectionInliner`. It holds the decision in one place: it classifies each definition of a projection by `DuplicationCost` (free, cheap, expensive, forbidden), counts the evaluations that the plan has after the rewrite, and reports the definitions that must stay where they are. Route the `OptimizeProjections` merge guard through it. The classification keeps the `should_push_to_leaves` split of that guard, so the plans do not change. See apache#8296 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…jection column `PushDownFilter` inlined every non-volatile definition of a projection into the pushed predicates. When the predicates reference an expensive definition more than one time, for example a column that `CommonSubexprEliminate` made for a struct-returning UDF, the push-down evaluates that expression more than one time. `CommonSubexprEliminate` then makes the projection again below the filter, so the two rules never converge. Route the rewrite through `ProjectionInliner` and delete the local `contain` helper and the placement partition. A predicate stays above the projection when it references a volatile definition, a `MoveTowardsLeafNodes` definition (as before), or an expensive definition that the predicates reference more than one time. A predicate with one reference is still pushed. Part of apache#25329 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ExtractLeafExpressions` and `PushDownLeafProjections` merge an extracted
field access into an input projection, and resolve its column references
to the definitions of that projection. When `CommonSubexprEliminate` made
the column for a struct-returning UDF, the merge puts a copy of the UDF
call next to the original, so the plan evaluates it two times:
Filter: CASE WHEN __common_expr_1 IS NOT NULL THEN __datafusion_extracted_2 IS NULL END
Projection: arrow_field(t.a) AS __common_expr_1, t.a,
get_field(arrow_field(t.a), 'nullable') AS __datafusion_extracted_2
Delete `would_duplicate_volatile`, `merge_would_duplicate_kept_expr` and
`build_projection_replace_map`, and use `ProjectionInliner` at every merge
site. The inliner counts the evaluations over the extracted expressions
and over the columns that the nodes above reference directly, so it also
sees the reference sites that `merge_would_duplicate_kept_expr` could not
see: they are in a different node.
Closes apache#25329
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add the two end-to-end shapes of the issue: the common sub-expression is referenced bare and through a field access, in a projection and in a filter. Both plans must show `arrow_field(a)` one time. Covers apache#23655 and apache#25329 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25456 +/- ##
==========================================
+ Coverage 82.33% 82.36% +0.02%
==========================================
Files 1137 1138 +1
Lines 432498 433234 +736
Branches 432498 433234 +736
==========================================
+ Hits 356115 356822 +707
- Misses 54844 54863 +19
- Partials 21539 21549 +10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
I haven't gone through the code changes in detail yet, but the description sounds like a good way forward. Implementing the assessment once rather than trying to patch things ad hoc per optimiser is the way to go. An open question I still had related to keep-in-place was if this should be refined a bit. On #25388 I tweaked |
| /// | ||
| /// The variants are in increasing order of cost. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | ||
| pub(crate) enum DuplicationCost { |
There was a problem hiding this comment.
This enumeration and the way DuplicationCost::of calculates it seems to overlap largely with the existing expression placement enumeration and Expr::placement. Are those two trying to express the same thing or is there a fundamental semantic difference?
…Cost` `DuplicationCost::of` returned `Free` as soon as the expression reported a placement of `MoveTowardsLeafNodes`, before it walked the expression for volatile nodes. A volatile function is free to report that placement, and `file_row_index()` does. Such a definition was classed `Free` and the rules inlined it into two sites. Volatility and subqueries now win over placement. `file_row_index.slt` keeps the extraction column and evaluates the call one time, which the new `EXPLAIN` there pins. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The name said the opposite of the doc comment. The method is for a projection that can stay in the plan for consumers that the caller can not see, so the consumers are the unknown ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Which issue does this PR close?
Rationale for this change
A function that keeps its place, for example a struct-returning UDF, is evaluated two times when the query hoists it into a common sub-expression and then reads one field of it inside a filter.
MRE:
Before, on
main.arrow_field(a)is in the plan two times, so it runs two times for each row.After, with this PR.
arrow_field(a)is in the plan one time:The same statement in a
SELECTlist runs the function one time since #23691. That fix counts the reference sites inside one projection. In the filter shape the two sites are in two different nodes, the filter and the extracted expression, so the count is 1 and the guard does not fire.CommonSubexprEliminateputs an expression into its own column so that the plan evaluates it one time. Four rules then replace a column reference with the expression that defines it. Each rule got its own guard against the duplication that this causes, one guard for each bug report:would_duplicate_volatile(3 call sites)ExtractLeafExpressions,PushDownLeafProjectionsmerge_would_duplicate_kept_expr(written by tohuya6, reviewed by kosiew; subsumed, not wrong, and its two regression tests stay)PushDownLeafProjectionsrewrite_projectionPushDownFiltermerge_consecutive_projections_one_levelOptimizeProjectionsEach guard sees only its own rule and its own node. This PR puts the decision in one place, so a rule can not miss a reference site again.
What changes are included in this PR?
A crate-private
ProjectionInliner(datafusion/optimizer/src/projection_inliner.rs) with one policy:MoveTowardsLeafNodesexpression), cheap (arithmetic,CAST,CASEand similar kernels), expensive (contains a scalar function that reportsKeepInPlace), or forbidden (volatile, or contains a subquery).The four rules call it instead of building their own replacement map:
OptimizeProjectionsaccepts free definitions only, which is the behaviour of Conflicting optimization rules:common_sub_expression_eliminateandpush_down_projection#8296. The reference count now resolves unqualified names too, which the oldHashMap<&Column, usize>did not, so the new code can pin a definition the old code merged. No plan in the test suites changes; a case where it would is a merge the old code should not have done.PushDownFilteraccepts cheap definitions. A predicate stays above the projection when it references a volatile definition, aMoveTowardsLeafNodesdefinition (as before), or an expensive definition that the predicates reference more than one time. A predicate with one reference is still pushed, so a cheap cast or an arithmetic expression still reaches the scan.ExtractLeafExpressionsandPushDownLeafProjectionsaccept free definitions only, and count the extracted expressions together with the columns that the nodes above reference directly. This is the fix for the issue.Deleted:
would_duplicate_volatile,volatile_output_columns,merge_would_duplicate_kept_expr,build_projection_replace_mapand thecontainhelper ofpush_down_filter.The commits are one for the new module, one for each rule, and one for the tests.
This PR is complementary to the draft #25388. That draft stops a push-down whenever the projection expression is
KeepInPlace, which is a wider rule: its own snapshots show that a cast and an arithmetic expression no longer reachpartial_filtersandfull_filters. This PR only stops a rewrite that evaluates a definition more times than before, which is a bug in every case. cc @pepijnveWhat is the testing strategy for this PR?
datafusion/sqllogictest/test_files/cse.slt: both end-to-end shapes, the projection shape of Optimize redundant calls to UDFs producingStructArray#23655 and the filter shape of this issue. Both plans must showarrow_field(a)one time.projection_inliner.rs: unit tests for the cost classification, the evaluation count, the substitution and the volatile case.push_down_filter.rs:filter_with_repeated_expensive_reference_not_pushed_through_projection,filter_with_single_expensive_reference_pushed_through_projectionandfilter_not_pushed_through_nested_computed_projection(the shape of the draft above, with the plan that the narrow policy gives).extract_leaf_expressions.rs:test_struct_returning_udf_in_filter_evaluated_once.No existing snapshot and no existing
.sltexpectation changed.Are there any user-facing changes?
Plans that evaluated a
KeepInPlaceexpression two times now evaluate it one time. There is no API change:ProjectionInlineris crate-private.Known remaining duplication sites
With the evaluation-site invariant from #25458 switched on, the
arrow_fieldduplication of #25329 no longer appears. Two leaf-rule shapes still evaluate aKeepInPlacedefinition at two sites and are not changed by this PR:named_struct(...)referenced from anORDER BYfield access (order.slt, throughextract_leaf_expressions) andnamed_struct(...)referenced from a field access above the projection (struct.slt, throughpush_down_leaf_projections). They are tracked in the EPIC and are the next step for the policy.Behaviour change to note: a projection definition that contains a subquery is now classed as forbidden to duplicate, so a predicate over such a column is no longer pushed below the projection. On
main,WHERE m > 0overSELECT (SELECT max(a) FROM t) AS mpushed the subquery below the projection while the projection still computed it.Volatility is now asked about before placement.
DuplicationCost::ofreturnedFreeas soon as a definition reported a placement ofMoveTowardsLeafNodes, before it walked the expression for volatile nodes, so a volatile function with that placement was classed free to inline at two sites.file_row_index()is such a function, andfile_row_index.sltshows it: the extraction column is now kept and both references read it, so the call is evaluated one time. A newEXPLAINin that file pins the plan, and a unit test next to the otherDuplicationCosttests assertsForbiddenfor a volatileMoveTowardsLeafNodesfunction.pinned_for_known_consumersis renamed topinned_with_unknown_consumers. The old name said the opposite of its own doc comment: the method is for a projection that can stay in the plan for consumers that the caller can not see.Part of the leaf-pushdown EPIC: #25459
🤖 Generated with Claude Code