perf: Use agg DistinctHandling in join optimization - #25385
neilconway wants to merge 4 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #25385 +/- ##
==========================================
+ Coverage 81.93% 82.32% +0.38%
==========================================
Files 1136 1137 +1
Lines 428779 432246 +3467
Branches 428779 432246 +3467
==========================================
+ Hits 351319 355825 +4506
+ Misses 56446 54851 -1595
- Partials 21014 21570 +556 ☔ 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
| }; | ||
| // Expr::is_volatile checks scalar functions only; check the aggregate | ||
| // function's own volatility separately. | ||
| aggregate.func.distinct_handling() == DistinctHandling::Insensitive |
There was a problem hiding this comment.
| aggregate.func.distinct_handling() == DistinctHandling::Insensitive | |
| (aggregate.params.distinct || aggregate.func.distinct_handling() == DistinctHandling::Insensitive) |
There was a problem hiding this comment.
Should we check distinct?
There was a problem hiding this comment.
Good idea! For Unsupported aggs, DISTINCT isn't quite strong enough, but I think this is sound if we check for either (a) Insensitive, or (b) Sensitive + DISTINCT. I made this change and added more tests.
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(()) | |
| } |
| # 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).
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 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.
Are there any user-facing changes?
Some query plans might change (usually for the better).