perf: Use agg DistinctHandling in join optimization - #25385
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25385 +/- ##
==========================================
- Coverage 82.38% 82.37% -0.02%
==========================================
Files 1138 1138
Lines 433776 434081 +305
Branches 433776 434081 +305
==========================================
+ Hits 357372 357560 +188
- Misses 54849 54885 +36
- Partials 21555 21636 +81 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @neilconway , there is a suggestion
There was a problem hiding this comment.
Thank you, Neil. The change looks correct to me, nice work!
Some minor update suggestions for the PR description:
-If we can prove that parts of a query are insensitive to duplicates, the optimizer apply various simplifications, like replacing inner joins with semi-joins and removing unused outer-join inputs.
+If we can prove that parts of a query are insensitive to duplicates, the optimizer can apply various simplifications, like replacing inner joins with semi-joins and removing unused outer-join inputs. Existing tests pass; new tests added.
+No TPC-H plans change.Once this review is addressed I'm good to merge this 🚀
| //! it `true` for its subtree, and it propagates downward until a node that | ||
| //! makes the row count observable again (a `LIMIT`, a top-N sort, a volatile | ||
| //! expression, ...) clears it. It is therefore fixed by the nearest such node, | ||
| //! not by the whole ancestor chain: a collapsing node shields its subtree, |
There was a problem hiding this comment.
Subqueries also clear the flag (see is_repeatable). Please name them here.
| //! it `true` for its subtree, and it propagates downward until a node that | |
| //! makes the row count observable again (a `LIMIT`, a top-N sort, a volatile | |
| //! expression, ...) clears it. It is therefore fixed by the nearest such node, | |
| //! not by the whole ancestor chain: a collapsing node shields its subtree, | |
| //! it `true` for its subtree, and it propagates downward until a node that | |
| //! makes the row count observable again (a `LIMIT`, a top-N sort, an | |
| //! expression that is not repeatable such as `random()` or a subquery, ...) | |
| //! clears it. It is therefore fixed by the nearest such node, not by the | |
| //! whole ancestor chain: a collapsing node shields its subtree, |
| fn volatile_expr() -> Expr { | ||
| ScalarUDF::from(PlacementTestUDF::new().with_volatility(Volatility::Volatile)) | ||
| .call(vec![col("l.x")]) | ||
| } | ||
|
|
||
| #[test] | ||
| fn volatile_aggregate_expressions_block_rewrite() -> Result<()> { | ||
| for (group_expr, aggr) in [ | ||
| (vec![], min(volatile_expr())), | ||
| (vec![volatile_expr()], min(col("l.x"))), | ||
| ( | ||
| vec![], | ||
| min(col("l.x")) | ||
| .filter(volatile_expr().gt(lit(0_u32))) | ||
| .build()?, | ||
| ), | ||
| ( | ||
| vec![], | ||
| min(col("l.x")) | ||
| .order_by(vec![volatile_expr().sort(true, false)]) | ||
| .build()?, | ||
| ), | ||
| ] { | ||
| let plan = left_join_right()? | ||
| .aggregate(group_expr, vec![aggr])? | ||
| .build()?; | ||
| assert!( | ||
| !EliminateJoin::new() | ||
| .rewrite(plan, &OptimizerContext::new())? | ||
| .transformed | ||
| ); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn volatile_intervening_expressions_block_rewrite() -> Result<()> { | ||
| for input in [ | ||
| left_join_right()?.project(vec![col("l.x"), volatile_expr().alias("v")])?, | ||
| left_join_right()?.filter(volatile_expr().gt(lit(0_u32)))?, | ||
| left_join_right()?.sort(vec![volatile_expr().sort(true, false)])?, | ||
| ] { | ||
| let plan = input | ||
| .aggregate(Vec::<Expr>::new(), vec![min(col("l.x"))])? | ||
| .build()?; | ||
| assert!( | ||
| !EliminateJoin::new() | ||
| .rewrite(plan, &OptimizerContext::new())? | ||
| .transformed | ||
| ); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
These tests only assert !transformed. If a different rule or a build error stops the rewrite, the tests still pass. Please add a Stable control, as join_conditions_must_be_repeatable does.
| fn volatile_expr() -> Expr { | |
| ScalarUDF::from(PlacementTestUDF::new().with_volatility(Volatility::Volatile)) | |
| .call(vec![col("l.x")]) | |
| } | |
| #[test] | |
| fn volatile_aggregate_expressions_block_rewrite() -> Result<()> { | |
| for (group_expr, aggr) in [ | |
| (vec![], min(volatile_expr())), | |
| (vec![volatile_expr()], min(col("l.x"))), | |
| ( | |
| vec![], | |
| min(col("l.x")) | |
| .filter(volatile_expr().gt(lit(0_u32))) | |
| .build()?, | |
| ), | |
| ( | |
| vec![], | |
| min(col("l.x")) | |
| .order_by(vec![volatile_expr().sort(true, false)]) | |
| .build()?, | |
| ), | |
| ] { | |
| let plan = left_join_right()? | |
| .aggregate(group_expr, vec![aggr])? | |
| .build()?; | |
| assert!( | |
| !EliminateJoin::new() | |
| .rewrite(plan, &OptimizerContext::new())? | |
| .transformed | |
| ); | |
| } | |
| Ok(()) | |
| } | |
| #[test] | |
| fn volatile_intervening_expressions_block_rewrite() -> Result<()> { | |
| for input in [ | |
| left_join_right()?.project(vec![col("l.x"), volatile_expr().alias("v")])?, | |
| left_join_right()?.filter(volatile_expr().gt(lit(0_u32)))?, | |
| left_join_right()?.sort(vec![volatile_expr().sort(true, false)])?, | |
| ] { | |
| let plan = input | |
| .aggregate(Vec::<Expr>::new(), vec![min(col("l.x"))])? | |
| .build()?; | |
| assert!( | |
| !EliminateJoin::new() | |
| .rewrite(plan, &OptimizerContext::new())? | |
| .transformed | |
| ); | |
| } | |
| Ok(()) | |
| } | |
| fn udf_expr(volatility: Volatility) -> Expr { | |
| ScalarUDF::from(PlacementTestUDF::new().with_volatility(volatility)) | |
| .call(vec![col("l.x")]) | |
| } | |
| #[test] | |
| fn volatile_aggregate_expressions_block_rewrite() -> Result<()> { | |
| // `Stable` is the control: the same shape with a repeatable | |
| // expression is rewritten. | |
| for volatility in [Volatility::Stable, Volatility::Volatile] { | |
| let expr = udf_expr(volatility); | |
| for (group_expr, aggr) in [ | |
| (vec![], min(expr.clone())), | |
| (vec![expr.clone()], min(col("l.x"))), | |
| ( | |
| vec![], | |
| min(col("l.x")) | |
| .filter(expr.clone().gt(lit(0_u32))) | |
| .build()?, | |
| ), | |
| ( | |
| vec![], | |
| min(col("l.x")) | |
| .order_by(vec![expr.clone().sort(true, false)]) | |
| .build()?, | |
| ), | |
| ] { | |
| let plan = left_join_right()? | |
| .aggregate(group_expr, vec![aggr])? | |
| .build()?; | |
| let result = EliminateJoin::new() | |
| .rewrite(plan.clone(), &OptimizerContext::new())?; | |
| assert_eq!( | |
| result.transformed, | |
| volatility != Volatility::Volatile, | |
| "{volatility:?}: {}", | |
| plan.display_indent(), | |
| ); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| #[test] | |
| fn volatile_intervening_expressions_block_rewrite() -> Result<()> { | |
| for volatility in [Volatility::Stable, Volatility::Volatile] { | |
| let expr = udf_expr(volatility); | |
| for input in [ | |
| left_join_right()?.project(vec![col("l.x"), expr.clone().alias("v")])?, | |
| left_join_right()?.filter(expr.clone().gt(lit(0_u32)))?, | |
| left_join_right()?.sort(vec![expr.clone().sort(true, false)])?, | |
| ] { | |
| let plan = input | |
| .aggregate(Vec::<Expr>::new(), vec![min(col("l.x"))])? | |
| .build()?; | |
| let result = EliminateJoin::new() | |
| .rewrite(plan.clone(), &OptimizerContext::new())?; | |
| assert_eq!( | |
| result.transformed, | |
| volatility != Volatility::Volatile, | |
| "{volatility:?}: {}", | |
| plan.display_indent(), | |
| ); | |
| } | |
| } | |
| Ok(()) | |
| } |
There was a problem hiding this comment.
Good catch, fixed.
| # REGR_COUNT does not implement DISTINCT and counts every joined row, so | ||
| # DISTINCT does not hide the join fanout and the join must stay an inner join. |
There was a problem hiding this comment.
regr_count declares Unsupported, and its accumulator ignores is_distinct. Thus 4 is the count without DISTINCT. The distinct count is 2. When plan-time checks for Unsupported are added, this result will change. Please write this in the comment.
| # REGR_COUNT does not implement DISTINCT and counts every joined row, so | |
| # DISTINCT does not hide the join fanout and the join must stay an inner join. | |
| # REGR_COUNT declares `DistinctHandling::Unsupported`: its accumulator ignores | |
| # `is_distinct` and counts every joined row (4 below, not 2), so DISTINCT does | |
| # not hide the join fanout and the join must stay an inner join. Plan-time | |
| # enforcement of `Unsupported` is a follow-up; update the results below when | |
| # it lands. |
| 02)--LeftSemi Join: join_t1.t1_id = join_t2.t2_id | ||
| 03)----TableScan: join_t1 projection=[t1_id, t1_name, t1_int] | ||
| 04)----TableScan: join_t2 projection=[t2_id] |
There was a problem hiding this comment.
The plan is now a semi join, and #22644 is closed. Please update the comment above this query (lines 1367–1368). GitHub cannot attach a suggestion there, because those lines are not in the diff.
-# A similar query with two DISTINCT aggregates is currently not rewritten
-# TODO: https://github.com/apache/datafusion/issues/22644
+# A similar query with two DISTINCT aggregates is also rewritten: each
+# `count(DISTINCT ...)` removes its own duplicates, so the join's duplicates
+# are not observable (see https://github.com/apache/datafusion/issues/22644).…distinct # Conflicts: # datafusion/optimizer/src/utils.rs
|
LGTM! |
|
@neilconway i realized we should probably run some benchmarks, do you mind running relevant benchmarks vs the HEAD commit preceding the merge? |
|
@adriangb I ran a few benchmarks on optimizer/planner perf; is this what you had in mind? Looks like some of the overhead would be relatively easy to address, I'll take a look. |
Thanks, good find. I was thinking of the full query bench suites, i’ll trigger some. |
Which issue does this PR close?
Rationale for this change
If we can prove that parts of a query are insensitive to duplicates, the optimizer can apply various simplifications, like replacing inner joins with semi-joins and removing unused outer-join inputs.
The join analysis was previously conservative and assumed that all aggregate expressions are duplicate sensitive. Since #25288 added a framework for classifying how an aggregate treats duplicate values, we can now apply that framework to optimize joins more effectively.
What changes are included in this PR?
EliminateJointo recognizeAggregateplan nodes whose aggregate expressions are all eitherDistinctHandling::InsensitiveorDistinctHandling::Sensitiveand invoked withDISTINCTWhat is the testing strategy for this PR?
Existing tests pass; new tests added. No TPC-H plans change.
Are there any user-facing changes?
Some query plans might change (usually for the better).