fix: eliminate group by constant empty input - #22132
Conversation
kumarUjjawal
left a comment
There was a problem hiding this comment.
Thanks @HairstonE Looks good.
@2010YOUY01 do you have any thoughts on this approach
|
This is on main: > create table foo as values (1);
0 row(s) fetched.
Elapsed 0.010 seconds.
> SELECT max(column1) FROM foo GROUP BY false HAVING false;
+------------------+
| max(foo.column1) |
+------------------+
| NULL |
+------------------+
1 row(s) fetched.
Elapsed 0.004 seconds.Postgres rejects postgres=# CREATE TABLE foo(column1 int);
insert into foo values (1);
SELECT max(column1) FROM foo GROUP BY false HAVING false;
CREATE TABLE
INSERT 0 1
ERROR: non-integer constant in GROUP BY
LINE 1: SELECT max(column1) FROM foo GROUP BY false HAVING false;DuckDB returns 0 rows: memory D CREATE TABLE foo(column1 int);
memory D insert into foo values (1);
memory D SELECT max(column1) FROM foo GROUP BY false HAVING false;
┌──────────────┐
│ max(column1) │
│ int32 │
└──────────────┘
0 rows |
| 02)--Aggregate: groupBy=[[]], aggr=[[count(Int64(1)), avg(t.c12)]] | ||
| 03)----SubqueryAlias: t | ||
| 04)------TableScan: test_table projection=[c12] | ||
| 01)Aggregate: groupBy=[[Int64(123), Int64(456), Int64(789)]], aggr=[[count(Int64(1)), avg(t.c12)]] |
There was a problem hiding this comment.
isn't this a significantly worse plan? it now must compute the grouping columns for each input row (even though they are constants)
There was a problem hiding this comment.
I am shooting for the DuckDB behavior. This gets the correct output on empty input. But yeah it will be slower on all inputs. Should I workshop this a bit more?
There was a problem hiding this comment.
Yes, I think we should avoid slowing down queries to implement a corner case. What about putting a Filter in the plan? Or maybe switching to use EmptyExec in some cases 🤔
There was a problem hiding this comment.
Alright, I'll draft this PR for now and look into those recommendations.
Thank you for the feedback!
There was a problem hiding this comment.
Update here -- this is still a significantly worse plan, however, as @neilconway points out in #23871 (comment) such a plan is likely not common (it produces a single group) so this is probably ok to change
2dd9ecb to
a67eb74
Compare
alamb
left a comment
There was a problem hiding this comment.
Sorry I forgot to submit my review
I am worried about this PR causing performance regressions for more common queries. Can we please try and find a way to scope the fix to just the queries where this is a problem?
| }; | ||
| let sentinel_name = | ||
| config.alias_generator().next("__row_count_sentinel"); | ||
| let count_udaf = registry.udaf("count")?; |
There was a problem hiding this comment.
this basically assumes that any function with the name count() will behave as the built in count 🤔 that doesn't seem safe to me (unless it is done elsewhere)
There was a problem hiding this comment.
replace_distinct_aggregate.rs line 134 does something similar with let first_value_udaf: Arc<datafusion_expr::AggregateUDF> = config.function_registry().unwrap().udaf("first_value")?;
Not saying that it's the right way to go just that it exists elsewhere. Otherwise, we could import count_udaf() from datafusion-functions-aggregate.
| let sentinel = count_udaf | ||
| .call(vec![lit(1_i64)]) | ||
| .alias(sentinel_name.as_str()); | ||
| let mut aggr_with_sentinel = aggregate.aggr_expr.clone(); |
| @@ -60,26 +60,42 @@ FROM test_table t | |||
| group by 1, 2, 3 | |||
| ---- | |||
| logical_plan | |||
There was a problem hiding this comment.
these plans are changing but I don't see the actual query results changing. Is that expected?
There was a problem hiding this comment.
These all have the new filter, but their outputs are unchanged because the filter passes whenever count > 0. The empty input tests are in aggregate.slt. Those tests on main would return 1 row of NULL, these changes return 0 rows.
|
This will only impact queries where every GROUP BY expression is constant. Any of those queries that don't already compute count(1)/count(*) will be slower ~0.9ns/row (this is from a microbench with 50M rows) but we get correctness on the corner case. |
Is there some way to fix this issue rather than adding a new filter (which has a per row cost)? For example, can we add a flag to the aggregate stream? |
|
If we want to add a flag to the aggregate stream I think that moves this to the physical planner, I can give this a try. It seems like something similar was attempted here #11897. |
|
I experimented with the flag in the physical planner and it is the best option. I'll close this and open another PR with those changes. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #22132 +/- ##
==========================================
+ Coverage 80.65% 80.67% +0.02%
==========================================
Files 1093 1095 +2
Lines 371619 372458 +839
Branches 371619 372458 +839
==========================================
+ Hits 299730 300485 +755
- Misses 53993 54043 +50
- Partials 17896 17930 +34 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
a67eb74 to
5644a58
Compare
alamb
left a comment
There was a problem hiding this comment.
Thank you @HairstonE
FYI @neilconway
| 02)--Aggregate: groupBy=[[]], aggr=[[count(Int64(1)), avg(t.c12)]] | ||
| 03)----SubqueryAlias: t | ||
| 04)------TableScan: test_table projection=[c12] | ||
| 01)Aggregate: groupBy=[[Int64(123), Int64(456), Int64(789)]], aggr=[[count(Int64(1)), avg(t.c12)]] |
There was a problem hiding this comment.
Update here -- this is still a significantly worse plan, however, as @neilconway points out in #23871 (comment) such a plan is likely not common (it produces a single group) so this is probably ok to change
neilconway
left a comment
There was a problem hiding this comment.
I took the liberty of updating the PR description to match the current approach. Two minor nits; otherwise lgtm!
| // Only eliminate when at least one required group expression remains. A grouping | ||
| // aggregate emits 0 rows on empty input; a global aggregate emits 1 NULL row. | ||
|
|
There was a problem hiding this comment.
Slightly inaccurate: ungrouped COUNT returns a row with a 0, not a NULL. How about this:
| // Only eliminate when at least one required group expression remains. A grouping | |
| // aggregate emits 0 rows on empty input; a global aggregate emits 1 NULL row. | |
| // Return now if no simplification can be done. We also bail out | |
| // if applying the optimization would eliminate all of the | |
| // grouping expressions (e.g., GROUP BY on only constant | |
| // expressions): this would turn a grouped aggregate into an | |
| // ungrouped aggregate, which changes query semantics (grouped | |
| // aggregates produce an empty result set on an empty input, | |
| // whereas ungrouped aggregates return a single row). |
There was a problem hiding this comment.
Rename this test func so it doesn't imply that we eliminate constant grouping keys in this case?
|
@neilconway I made both those changes you suggested. Should be ready to merge! |
|
Thanks @HairstonE ! Sorry again for the back-and-forth on this issue, I think we ended up with the right approach. |
|
@neilconway No worries at all, the back and forth was great to learn more about the codebase. Thank you and @alamb for the reviews and feedback! |
|
I agree -- thanks again @HairstonE and @neilconway |
Which issue does this PR close?
Rationale for this change
EliminateGroupByConstantremoves GROUP BY expressions that are constants. If all of the GROUP BY expressions are constants, eliminating all of them results in converting a grouped aggregate into an ungrouped (global) aggregate. This changes the semantics of the query: a grouped aggregate query on an empty input returns zero rows, whereas an ungrouped aggregate query produces a single row.What changes are included in this PR?
EliminateGroupByConstantnow declines to eliminate constant GROUP BY expressions, if doing so would result in removing all of the grouping expressions.Are these changes tested?
Yes. Sqllogictest reproducer for the original issue, updated unit test and optimizer SLT expectations.
Are there any user-facing changes?
Queries with all-constant GROUP BY may now return fewer (correct) rows.