jayzhan211 commented on code in PR #25988:
URL: https://github.com/apache/datafusion/pull/25988#discussion_r4499130540
##########
datafusion/optimizer/src/single_distinct_to_groupby.rs:
##########
@@ -154,12 +155,41 @@ fn is_single_distinct_agg(
if aggregate_count != aggr_expr.len() || fields_set.len() != 1 {
return Ok(false);
}
+ // With one target partition, a native GroupsAccumulator avoids building an
+ // extra aggregate with a row per (group, distinct value) pair. With
multiple
+ // partitions, the rewrite also distributes deduplication by both keys;
+ // keeping DISTINCT instead repartitions lists of values by group and
merges
+ // them in the final accumulator. That can concentrate work on a few
groups.
+ // Native accumulator support alone does not establish that this is
cheaper.
+ if use_native_grouped_distinct
Review Comment:
@QinXi-ai Thanks for working on this. With spilling on, this turns queries
that finish under a memory limit into `ResourcesExhausted`: the rewrite dedupes
through sorted `(g, x)` spill runs, but the native accumulator has to hold
every distinct value of the largest group in memory. ClickBench Q8 at one
partition finishes at `-m 16M` on main and fails at `-m 128M` here (it passes
at 256M). The equal-memory table in #25936 used `-d 0`, where neither plan can
spill.
```sql
SET datafusion.execution.target_partitions = 1;
SET datafusion.execution.batch_size = 128;
SET datafusion.runtime.memory_limit = '1M';
-- main: 2, 100000. This PR: Resources exhausted
SELECT count(*), sum(d) FROM (
SELECT v % 2 AS k, count(DISTINCT v) AS d
FROM generate_series(1, 100000) AS t(v)
GROUP BY v % 2
);
```
The rule can't see the memory pool, so it can't choose per query. Could you
make the native plan opt-in so the rewrite stays the default? #26000 adds a
very similar flag to this rule, so it's worth settling on one.
```rs
!group_expr.is_empty()
&& config.options().execution.target_partitions == 1
&& config.options().optimizer.single_distinct_prefer_native
```
Please add the query above to `aggregate_memory_spill.slt` as the flag-off
test, plus an `EXPLAIN` with the flag on showing the native plan. If a
maintainer would rather keep it on by default, it needs an upgrade guide note,
and the "Query results ... are unchanged" line in the description needs
correcting.
##########
datafusion/optimizer/src/single_distinct_to_groupby.rs:
##########
@@ -154,12 +155,41 @@ fn is_single_distinct_agg(
if aggregate_count != aggr_expr.len() || fields_set.len() != 1 {
return Ok(false);
}
+ // With one target partition, a native GroupsAccumulator avoids building an
+ // extra aggregate with a row per (group, distinct value) pair. With
multiple
+ // partitions, the rewrite also distributes deduplication by both keys;
+ // keeping DISTINCT instead repartitions lists of values by group and
merges
+ // them in the final accumulator. That can concentrate work on a few
groups.
+ // Native accumulator support alone does not establish that this is
cheaper.
+ if use_native_grouped_distinct
+ && distinct_aggs.len() == aggregate_count
+ && all_have_groups_accumulators(&distinct_aggs, input_schema)?
+ {
+ return Ok(false);
+ }
if has_count_rollup && !rewrite_pays_for_count(&distinct_aggs,
input_schema)? {
return Ok(false);
}
Ok(true)
}
+fn all_have_groups_accumulators(
Review Comment:
Optional cleanup: this repeats the loop in `rewrite_pays_for_count`, and the
two only differ in which answer they look for. One helper covers both:
```diff
-fn all_have_groups_accumulators(
+/// Each distinct aggregate's answer to whether a specialized
`GroupsAccumulator`
+/// exists for its argument types.
+fn distinct_groups_support(
distinct_aggs: &[(&Arc<AggregateUDF>, &[Expr])],
input_schema: &DFSchema,
-) -> Result<bool> {
- for (func, args) in distinct_aggs {
- let arg_types = args
- .iter()
- .map(|arg| arg.get_type(input_schema))
- .collect::<Result<Vec<_>>>()?;
- // An unknown answer must preserve the existing rewrite.
- if func.groups_accumulator_supported_for_types(&arg_types, true) !=
Some(true) {
- return Ok(false);
- }
- }
- Ok(true)
+) -> Result<Vec<Option<bool>>> {
+ distinct_aggs
+ .iter()
+ .map(|(func, args)| {
+ let arg_types = args
+ .iter()
+ .map(|arg| arg.get_type(input_schema))
+ .collect::<Result<Vec<_>>>()?;
+ Ok(func.groups_accumulator_supported_for_types(&arg_types,
true))
+ })
+ .collect()
}
```
The call here becomes `distinct_groups_support(&distinct_aggs,
input_schema)?.iter().all(|support| *support == Some(true))`, and
`rewrite_pays_for_count` becomes `Ok(distinct_groups_support(distinct_aggs,
input_schema)?.contains(&Some(false)))`.
##########
benchmarks/sql_benchmarks/grouped_count_distinct/init/load.sql:
##########
@@ -0,0 +1,11 @@
+CREATE TABLE grouped_distinct AS
+SELECT value % 2000 AS g,
+ value % 8 AS few_g,
+ (value * 48271) % 999983 AS x,
+ (value / 2000) % 8 AS low_x,
+ arrow_cast((value * 48271) % 999983, 'UInt64') AS unsigned_x,
+ CAST((value * 48271) % 999983 AS VARCHAR) AS string_x,
+ CAST((value * 48271) % 999983 AS DOUBLE) AS float_x,
+ CASE WHEN value % 100 < 90 THEN 0 ELSE value % 8 END AS skew_g,
+ CAST(CASE WHEN value % 100 < 90 THEN 0 ELSE value % 8 END AS VARCHAR)
AS string_g
Review Comment:
The suite doesn't include the shape where the native plan loses: many groups
with duplicate rows. With 1M groups and four rows per group, I measured it
about 1.4x slower than main (about 130 ms vs 93 ms median, on a busy laptop),
because every row probes both the group table and `seen`, while the rewrite's
outer aggregate only sees each pair once. The rule has no statistics to avoid
this, so could you add a `q10` over these columns and state this worst case in
the description?
```suggestion
CAST(CASE WHEN value % 100 < 90 THEN 0 ELSE value % 8 END AS VARCHAR)
AS string_g,
value % 1000000 AS many_g,
value % 2 AS dup_x
```
##########
datafusion/optimizer/src/single_distinct_to_groupby.rs:
##########
@@ -231,6 +261,8 @@ impl OptimizerRule for SingleDistinctToGroupBy {
&aggr_expr,
input.schema(),
count_rollup.as_ref(),
+ !group_expr.is_empty()
+ && config.options().execution.target_partitions == 1,
Review Comment:
`target_partitions == 1` doesn't guarantee one input partition: a `UNION
ALL` or a table created at higher parallelism still plans Partial, Coalesce,
Final, which is the list-merge path this PR avoids for parallel plans. A
4-partition table with a hot group ran about 1.3x slower than main in my runs,
since Partial dedupes first and Final rebuilds the set. Could you add this test
before the switch to four partitions, so that path is covered, and mention the
case in the comment above `use_native_grouped_distinct`?
```sql
query II rowsort
SELECT g, count(DISTINCT x)
FROM (SELECT g, x FROM numeric_counts UNION ALL SELECT g, x FROM
numeric_counts)
GROUP BY g;
----
1 2
2 0
3 1
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]