Repository navigation
feat: rewrite inner joins against a single constant row into a filter - #26404
Conversation
AggregateStatistics can fold a scalar subquery into a row of literals, but a join against that row still runs as a NestedLoopJoinExec, which preserves neither input's ordering. EliminateConstantJoin substitutes the literals into the join filter and replaces the join with a FilterExec and a ProjectionExec, which keep the other input's ordering and expose the bound for scan pruning. Closes apache#26349.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #26404 +/- ##
==========================================
+ Coverage 82.89% 82.92% +0.03%
==========================================
Files 1154 1155 +1
Lines 458022 457927 -95
Branches 458022 457927 -95
==========================================
+ Hits 379683 379745 +62
+ Misses 55303 55151 -152
+ Partials 23036 23031 -5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
asolimando
left a comment
There was a problem hiding this comment.
LGTM, this is a clever optimization, I left a few minor comments
| .expr() | ||
| .iter() | ||
| .map(|ProjectionExpr { expr, .. }| { | ||
| if expr.is::<Literal>() { |
There was a problem hiding this comment.
Nit: ConstEvaluator seems to be always constant-folding constant expressions (no column references, no volatile functions), we could possibly add a comment around that, as at first sight it looked too restrictive to me, and I had to play with a few SLT tests to verify.
There was a problem hiding this comment.
Checked this: constants written in SQL are folded by the logical simplifier, but values folded by AggregateStatistics only become literals during physical planning, so (SELECT min(ts) + 1 ...) reached the rule as 25 + 1 and was skipped. The rule now substitutes the row below and folds with PhysicalExprSimplifier, accepting only a literal result; volatile expressions and ones that fail to evaluate are still rejected. Added a comment and an slt case in b9fa393.
| b 20 25 | ||
| c 5 25 | ||
|
|
||
| # A left join keeps the constant row when nothing matches |
There was a problem hiding this comment.
Nit: I think an EXPLAIN here would help to show the bail-out of the rule, beyond the correct result returned
There was a problem hiding this comment.
Added an EXPLAIN showing the left join stays a NestedLoopJoinExec (b9fa393).
| } | ||
|
|
||
| /// Rewrites `join` over its non-constant input, if the other input is a constant row. | ||
| fn eliminate(join: &NestedLoopJoinExec) -> Result<Option<Arc<dyn ExecutionPlan>>> { |
There was a problem hiding this comment.
This optimization would work also for CrossJoinExec, I think we could either add support for it (it doesn't seem much work from a cursory look), or document the gap and possibly file a follow-up issue. wdyt?
There was a problem hiding this comment.
Added in b9fa393. CrossJoinExec drops input ordering and, when the other input has no statistics, buffers it as the build side, so the rewrite helps there as well. This changes the j1, LATERAL (SELECT 1) plans in cte.slt and joins.slt.
- Fold the constant side's expressions at planning time, so values over a statistics-folded aggregate, such as `min(ts) + 1`, are recognized. Volatile expressions and ones that fail to evaluate are still rejected. - Rewrite `CrossJoinExec` against a constant row too. It drops input ordering and may buffer the other input as its build side. - Show in an EXPLAIN that a left join against a constant row stays a join.
…apache#26404) ## Which issue does this PR close? - Closes apache#26349. ## Rationale for this change `AggregateStatistics` can answer a scalar subquery such as `(SELECT min(ts) FROM live)` from statistics and replace it with a row of literals. A join against that row still runs as a `NestedLoopJoinExec`, or as a `CrossJoinExec` when the join has no condition. That join only filters its other input, but neither join operator preserves its inputs' ordering. Operators above it therefore lose the input's ordering. For example, an aggregation grouped by the input's sort key can no longer emit each group as soon as its sort key changes (`group_clustering_mode=Full`) and must hold every group until the end. The literal bound is also not pushed into the scan for pruning. ## What changes are included in this PR? A new physical optimizer rule, `EliminateConstantJoin`, runs right after `AggregateStatistics`. It applies when one input of an inner `NestedLoopJoinExec` or a `CrossJoinExec` is provably a single row of literals: projections over a single-partition `PlaceholderRowExec`. The rule substitutes the literals into the join filter. It then replaces the join with a `FilterExec` over the other input, under a `ProjectionExec` that restores the join's output schema and metadata. A cross join has no filter, so it becomes only the projection. Besides dropping ordering, `CrossJoinExec` buffers its other input as the build side when that input has no statistics. At this point in the optimizer, a folded `min(ts) AS first_ts` is still an alias projection over the literal projection, so the rule follows stacked projections. Expressions over a folded value, such as `min(ts) + 1`, only become constant at this point, so the rule folds them with `PhysicalExprSimplifier`. The rule leaves the join unchanged when: - the join is not an inner join; - the placeholder has more than one partition, because it emits one row per partition; - a value does not fold to a literal; for example, `random()` would then be evaluated per row instead of once; - the join filter contains a lambda, because lambda variables are numbered after the filter's own columns, not the other input's. ## What is the testing strategy for this PR? - `eliminate_constant_join.slt` has plans and results for four rewrites: a scalar folded from Parquet statistics, an expression over it (`min(ts) + 1`), a cross join, and a literal subquery. In the first and the cross join, the aggregation becomes `group_clustering_mode=Full`; the bound is pushed into the Parquet scan. The file also checks that a left join and a `random()` constant keep the join. - `physical_optimizer/eliminate_constant_join.rs` covers cases SQL cannot reach: - a multi-partition placeholder; - a lambda join filter, planned without optimizer rules because projection pushdown cannot plan it yet (apache#26399); - schema metadata that comes from the constant input. - `explain.slt`: `EXPLAIN VERBOSE` lists the new rule. - `cte.slt`, `joins.slt`: `j1, LATERAL (SELECT 1)` now plans a projection instead of a `CrossJoinExec`. ## Are there any user-facing changes? Queries that join against a row folded from statistics, or against a literal subquery, now plan a filter and a projection instead of a nested loop join or cross join. `EXPLAIN VERBOSE` and `PhysicalOptimizer::new()` include the new rule. There are no API changes. (cherry picked from commit 2c2ecae) Adapt SQL plan expectations to branch-55, which still uses ordering_mode=Sorted instead of group_clustering_mode=Full.
Which issue does this PR close?
Rationale for this change
AggregateStatisticscan answer a scalar subquery such as(SELECT min(ts) FROM live)from statistics and replace it with a row of literals. A join against that row still runs as aNestedLoopJoinExec, or as aCrossJoinExecwhen the join has no condition.That join only filters its other input, but neither join operator preserves its inputs' ordering. Operators above it therefore lose the input's ordering. For example, an aggregation grouped by the input's sort key can no longer emit each group as soon as its sort key changes (
group_clustering_mode=Full) and must hold every group until the end. The literal bound is also not pushed into the scan for pruning.What changes are included in this PR?
A new physical optimizer rule,
EliminateConstantJoin, runs right afterAggregateStatistics. It applies when one input of an innerNestedLoopJoinExecor aCrossJoinExecis provably a single row of literals: projections over a single-partitionPlaceholderRowExec. The rule substitutes the literals into the join filter. It then replaces the join with aFilterExecover the other input, under aProjectionExecthat restores the join's output schema and metadata. A cross join has no filter, so it becomes only the projection. Besides dropping ordering,CrossJoinExecbuffers its other input as the build side when that input has no statistics.At this point in the optimizer, a folded
min(ts) AS first_tsis still an alias projection over the literal projection, so the rule follows stacked projections. Expressions over a folded value, such asmin(ts) + 1, only become constant at this point, so the rule folds them withPhysicalExprSimplifier.The rule leaves the join unchanged when:
random()would then be evaluated per row instead of once;What is the testing strategy for this PR?
eliminate_constant_join.slthas plans and results for four rewrites: a scalar folded from Parquet statistics, an expression over it (min(ts) + 1), a cross join, and a literal subquery. In the first and the cross join, the aggregation becomesgroup_clustering_mode=Full; the bound is pushed into the Parquet scan. The file also checks that a left join and arandom()constant keep the join.physical_optimizer/eliminate_constant_join.rscovers cases SQL cannot reach:explain.slt:EXPLAIN VERBOSElists the new rule.cte.slt,joins.slt:j1, LATERAL (SELECT 1)now plans a projection instead of aCrossJoinExec.Are there any user-facing changes?
Queries that join against a row folded from statistics, or against a literal subquery, now plan a filter and a projection instead of a nested loop join or cross join.
EXPLAIN VERBOSEandPhysicalOptimizer::new()include the new rule. There are no API changes.