adriangb opened a new issue, #24929:
URL: https://github.com/apache/datafusion/issues/24929

   ### Describe the bug
   
   `SingleDistinctToGroupBy` rewrites `AGG(DISTINCT x)` into an inner `GROUP BY 
(group_keys, x)` plus an outer aggregate. The inner aggregate then holds one 
row per distinct `(group_keys, x)` pair instead of one row per group, and every 
other aggregate in the query moves down to that finer grain.
   
   The rule decides whether to fire from **which aggregate functions appear** 
in the query, not from whether the rewrite is actually cheaper. 
`is_single_distinct_agg` accepts the plan when every non-distinct aggregate is 
`sum`, `min` or `max`, and rejects it otherwise. That condition has nothing to 
do with the cost of the rewrite, so the rule fires on plans where it is a pure 
loss.
   
   The clearest symptom is that **adding an aggregate to the select list makes 
the query about 1000x cheaper in memory**, even though the extra aggregate 
makes the query do strictly more work. `count` is not in the tolerated set, so 
a `count(*)` disqualifies the plan from the rewrite and the query gets much 
cheaper.
   
   ### To Reproduce
   
   Using `datafusion-cli` on `main`:
   
   ```sql
   CREATE TABLE t AS
   SELECT v % 2000 AS g, (v * 48271) % 999983 AS x, v % 1000 AS y
   FROM (SELECT unnest(generate_series(0, 3999999)) AS v);
   -- 4,000,000 rows, 2,000 groups, ~4,000,000 distinct (g, x) pairs
   ```
   
   The two plans differ only by the `count(*)`:
   
   ```
   > EXPLAIN SELECT g, min(DISTINCT x) FROM t GROUP BY g;
   Projection: t.g, min(alias1) AS min(DISTINCT t.x)
     Aggregate: groupBy=[[t.g]], aggr=[[min(alias1)]]
       Aggregate: groupBy=[[t.g, t.x AS alias1]], aggr=[[]]
         TableScan: t projection=[g, x]
   
   > EXPLAIN SELECT g, count(*), min(DISTINCT x) FROM t GROUP BY g;
   Projection: t.g, count(Int64(1)) AS count(*), min(DISTINCT t.x)
     Aggregate: groupBy=[[t.g]], aggr=[[count(Int64(1)), min(DISTINCT t.x)]]
       TableScan: t projection=[g, x]
   ```
   
   The rewritten plan builds a 4,000,000 row hash table. The plan that also 
computes `count(*)` keeps 2,000 groups.
   
   Under a bounded memory pool the smaller query is the one that fails. Run 
each query with `datafusion-cli -m <limit> --mem-pool-type greedy`:
   
   | query | rewritten | smallest limit that succeeds |
   | --- | --- | --- |
   | `SELECT g, min(DISTINCT x) FROM t GROUP BY g` | yes | 48M (fails at 40M) |
   | `SELECT g, count(*), min(DISTINCT x) FROM t GROUP BY g` | no | 512K (fails 
at 256K) |
   
   At `-m 32M` the first query fails and the second, which computes strictly 
more, succeeds:
   
   ```
   Error: Resources exhausted: Additional allocation failed for 
FinalHashAggregateStream[5] ...
     greedy(used: 31.8 MB, pool_size: 32.0 MB)
   ```
   
   Two more pairs behave the same way:
   
   | query | rewritten | smallest limit that succeeds |
   | --- | --- | --- |
   | `SELECT g, sum(y), sum(DISTINCT x) FROM t GROUP BY g` | yes | 64M |
   | `SELECT g, count(*), sum(y), sum(DISTINCT x) FROM t GROUP BY g` | no | 24M 
|
   
   ### Expected behavior
   
   Adding an aggregate to a query should not reduce its memory requirement by 
three orders of magnitude. The rewrite should not fire on plans where it is 
strictly more expensive than the plan it replaces.
   
   ### Additional context
   
   #### Measured peak memory
   
   To attribute the cost to the rule rather than to the memory limit, I ran 
each query twice on identical data, once with the rule in the optimizer 
pipeline and once with it removed, recording the high water mark of 
`MemoryPool::reserved()` under an unbounded pool with `target_partitions = 1`. 
Results are identical in both cases. 4,000,000 rows, `x` drawn from 1,000,000 
values, `sum(y)` as the non-distinct companion.
   
   | distinct aggregate | groups | type | rule off | rule on | |
   | --- | --- | --- | --- | --- | --- |
   | `count(DISTINCT x)` | 500,000 | BIGINT | 195.9 MiB | 223.4 MiB | 1.14x 
worse |
   | `count(DISTINCT x)` | 500,000 | VARCHAR | 4075.2 MiB | 271.4 MiB | 15x 
better |
   | `count(DISTINCT x)` | 2,000 | BIGINT | 168.2 MiB | 223.4 MiB | 1.33x worse 
|
   | `sum(DISTINCT x)` | 2,000 | BIGINT | 71.0 MiB | 223.4 MiB | 3.15x worse |
   | `avg(DISTINCT x)` | 2,000 | BIGINT | 71.0 MiB | 223.4 MiB | 3.15x worse |
   | `min(DISTINCT x)` | 2,000 | BIGINT | 0.2 MiB | 223.4 MiB | 1113x worse |
   | `array_agg(DISTINCT x)` | 2,000 | BIGINT | 392.6 MiB | 223.4 MiB | 1.8x 
better |
   
   Process peak RSS for the `min(DISTINCT x)` pair is 1062 MiB against 784 MiB, 
so this is real memory rather than an accounting artifact.
   
   Using `sum`, `min` or `max` as the non-distinct companion gives 
byte-identical numbers. The companion aggregate does not affect the mechanism, 
only whether the rule fires.
   
   #### Why the outcome varies
   
   What decides the result is the storage cost per distinct value on each side. 
The rewrite materializes one hash table row per distinct `(group_keys, x)` 
pair, plus an accumulator slot per other aggregate at that grain. It wins when 
the unrewritten accumulator costs more than that per value, such as a per-group 
hash set of strings or an `array_agg`. It loses when the unrewritten 
accumulator costs less.
   
   `min` and `max` are the extreme case, because `min(DISTINCT x)` equals 
`min(x)`. `min_max.rs` correctly never inspects `is_distinct`, so the 
unrewritten plan costs one scalar per group while the rewrite builds a hash 
table with one row per distinct pair.
   
   #### Note on removing the rule
   
   The numbers above also show that removing the rule outright would be a 
regression. It is 15x better for `count(DISTINCT <string>)` at high group 
cardinality and about 2x better for `array_agg(DISTINCT)`. A gate that reflects 
when the rewrite actually helps looks more promising than either the current 
function-name condition or a blanket removal.
   
   #### Related work
   
   - #11360 asks whether the rule is still needed now that distinct 
accumulators exist, with a ClickBench comparison that found no clear advantage 
from deleting it. That thread measures planning and runtime and asks whether 
the rule is *unnecessary*. This report is about the rule being *actively 
harmful* on a class of plans, on a trigger condition unrelated to its benefit, 
and it also shows the rule is genuinely valuable on other plans.
   - #8266, closing #8123, added the tolerance for non-distinct `sum`, `min` 
and `max`. That tolerance is what lets the rule fire on the `min(DISTINCT)` and 
`sum(DISTINCT)` cases above.
   - #20782 proposed skipping the rewrite for `count(DISTINCT)` with no `GROUP 
BY`, on the same grounds that the direct distinct accumulator is cheaper. It 
was not merged.
   - #11686 proposes eliminating `DISTINCT` on `min` and `max` early, which 
would remove the worst case here at its source.
   - #20942 and #21087 cover the multiple-distinct case, which this rule does 
not handle at all.
   - #24704 tracks blocked and chunked memory management in hash aggregation.
   
   #### Version
   
   Reproduced on `main` at `d2b626cc9` (`datafusion-cli 55.0.0`), macOS arm64. 
`single_distinct_to_groupby.rs`, `min_max.rs` and `count.rs` are unchanged at 
`20d1c5676`, the current `main` head at the time of writing.
   


-- 
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]

Reply via email to