Allow a non-distinct count alongside the single distinct aggregate in SingleDistinctToGroupBy - #72
Allow a non-distinct count alongside the single distinct aggregate in SingleDistinctToGroupBy#72adriangb wants to merge 5 commits into
Conversation
`SingleDistinctToGroupBy` rewrites `AGG(DISTINCT x)` into a two phase group by, which is what keeps a high cardinality distinct off the one-accumulator-per-group path in `GroupsAccumulatorAdapter`. The rule tolerated a non-distinct `sum`, `min` or `max` next to the distinct aggregate but bailed out on `count`, so the very common `count(*), count(DISTINCT x) ... GROUP BY` shape kept the unrewritten plan and its memory profile. Allow a non-distinct `count` as well. `count` is the one supported function whose outer phase is a different function: the inner group by counts the rows of each `(group, distinct value)` partition and the outer phase adds those partial counts up with `sum`, since count over a group is the sum of the counts of any partition of that group. Two details follow from that substitution: - `count` and `sum` are resolved from the session function registry and the rewrite only fires when the aggregate is that exact `count`, so a session without a registry or with its own `count` is left alone. - `count` returns a non-null 0 over an empty input while `sum` of no rows is NULL, which is reachable for an aggregate with no group by. The projection selects `CASE WHEN sum(alias) IS NOT NULL THEN sum(alias) ELSE 0 END`, which restores the 0 and keeps the column's type and nullability as `count` had them. The new sqllogictest file asserts every result twice, once with the optimizer disabled and once with it enabled, over data with NULL and all-NULL distinct values, an empty input, and `count(*)` versus `count(col)` versus `count(1)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| // if the aggregate function is not distinct, we need to rewrite it like two phase aggregation | ||
| } else { | ||
| index += 1; | ||
| let alias_str = format!("alias{index}"); |
There was a problem hiding this comment.
🟠 High src/single_distinct_to_groupby.rs:288
The rewrite now fails for queries that group by a column named alias2, such as SELECT alias2, count(*), count(DISTINCT b) ... GROUP BY alias2, instead of producing an executable plan. The non-distinct aggregate is always assigned alias2, so the inner aggregate contains both the grouped alias2 field and count(...) AS alias2; DFSchema::check_names rejects these duplicate or ambiguous names. Generate an internal alias that is collision-free with the grouping fields and other generated aliases.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @datafusion/optimizer/src/single_distinct_to_groupby.rs around line 288:
The rewrite now fails for queries that group by a column named `alias2`, such as `SELECT alias2, count(*), count(DISTINCT b) ... GROUP BY alias2`, instead of producing an executable plan. The non-distinct aggregate is always assigned `alias2`, so the inner aggregate contains both the grouped `alias2` field and `count(...) AS alias2`; `DFSchema::check_names` rejects these duplicate or ambiguous names. Generate an internal alias that is collision-free with the grouping fields and other generated aliases.
| let registry = config.function_registry()?; | ||
| Some(Self { | ||
| count: registry.udaf("count").ok()?, | ||
| sum: registry.udaf("sum").ok()?, |
There was a problem hiding this comment.
🟠 High src/single_distinct_to_groupby.rs:109
A custom UDAF registered under sum causes this rewrite to replace a normal count with that UDAF over partial counts, producing non-count results. try_new verifies only the registered count, so it must also ensure the selected rollup has the built-in additive sum semantics before enabling the rewrite.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @datafusion/optimizer/src/single_distinct_to_groupby.rs around line 109:
A custom UDAF registered under `sum` causes this rewrite to replace a normal `count` with that UDAF over partial counts, producing non-`count` results. `try_new` verifies only the registered `count`, so it must also ensure the selected rollup has the built-in additive `sum` semantics before enabling the rewrite.
`aggregate_distinct_with_having` round trips `SELECT a, count(distinct b) ... HAVING count(b) > 100` through substrait and asserts the plan comes back displaying identically. It passed only because the non-distinct `count` made `SingleDistinctToGroupBy` bail out, so the plan had no aliases in it. With the rule now allowing that `count`, the query is rewritten and the assertion fails. The failure is a pre-existing substrait gap rather than anything specific to this query: substrait carries no names for an aggregate's grouping and measure expressions, so the consumer derives them from the expressions themselves and the `alias1` and `alias2` names the rule introduces are lost. Any plan the rule rewrites fails the same way, including the plain `SELECT a, count(distinct b) FROM data GROUP BY a, c` that this change does not touch. Remove the rule from the session used by this one test, so it keeps covering the un-rewritten aggregate it was written for instead of depending on the rule bailing out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15439ad to
f47c045
Compare
|
Rebased from the DataFusion 55 fork branch ( Revalidated on the new base:
One new commit was needed. The substrait roundtrip test |
A memory limited ClickBench run contradicted the memory rationale for the
case it measures. Q22 is `SELECT "SearchPhrase", MIN("URL"), MIN("Title"),
COUNT(*), COUNT(DISTINCT "UserID") ... GROUP BY "SearchPhrase"`, the only
plan the change touched, and its peak memory pool reservation went from
3.1 MiB to 7.3 MiB, up 132.2%, with neighbouring queries moving by a few
percent either way.
`UserID` is an `Int64`, and `Count::groups_accumulator_supported` returns
true for every integer type and false for everything else. So the base
plan already had `PrimitiveDistinctCountGroupsAccumulator` and never went
near `GroupsAccumulatorAdapter`. The rewrite replaced a compact vectorized
accumulator with an inner group by on `(SearchPhrase, UserID)`, which also
carries `min("URL")` and `min("Title")` at that much finer grain, and
bought nothing back.
Measured locally on 4M rows, 500k groups and 2M distinct pairs, peak pool
reservation for `SELECT g, count(*), count(DISTINCT x) FROM t GROUP BY g`:
x BIGINT 135 MiB unrewritten, 205 MiB rewritten
x VARCHAR 13.6 GiB unrewritten, 255 MiB rewritten
Peak RSS for the VARCHAR pair was 18.0 GiB against 433 MiB, so the pool
figure is real memory rather than an accounting artifact. The rewrite pays
exactly when the distinct argument would otherwise land on the adapter.
`datafusion-optimizer` cannot depend on `datafusion-functions-aggregate` to
read that list of types, and has no `AccumulatorArgs` to ask
`groups_accumulator_supported` with. Its `Cargo.toml` names the way out, so
this adds `AggregateUDFImpl::groups_accumulator_supported_for_types`,
defaulting to false as `groups_accumulator_supported` does. `Count` is the
only implementor and its physical method now delegates to it, so the two
cannot disagree.
The gate covers only the `count` this branch added. A plan that already
qualified through a non-distinct `sum`, `min` or `max` is rewritten exactly
as before, over any distinct argument type.
The ClickBench snapshot returns to its base form, so no existing snapshot
in the repository changes. `single_distinct_to_groupby.slt` now carries the
same values in a `VARCHAR` and an `INT` column and asserts both sides of
the gate, still under both `datafusion.optimizer.max_passes = 0` and the
default.
`cargo doc` runs with `-D warnings`, and a doc link from the public `SingleDistinctToGroupBy` to the private `rewrite_pays_for_count` is an error. Say the same thing in prose instead.
`AggregateUDFImpl::groups_accumulator_supported_for_types` returned `bool` with a `false` default, and `rewrite_pays_for_count` read `false` as "the rewrite pays". Every aggregate that did not override the method was therefore waved through the gate, which is the permissive answer rather than the safe one. Return `Option<bool>` instead, defaulting to `None`, and rewrite only on `Some(false)`. `None` says the aggregate does not answer the question, and silence is not evidence that the rewrite pays. Measured on unmodified upstream, over 4,000,000 rows in 2,000 groups, `SELECT g, count(*), sum(DISTINCT int_col) FROM t GROUP BY g` reached the gated path and regressed 3.15x: 71.0 MiB unrewritten against 223.4 MiB rewritten. `min(DISTINCT x)` reached it too, and regresses much further, because `min(DISTINCT x)` is `min(x)` and the unrewritten plan keeps one scalar per group. `Count` is the only implementor and its answers are unchanged, so no plan that the gate already allowed changes shape.
|
Superseded by the upstream pull request: apache#24859 This copy existed only to run the change through review here before sending it upstream. That is done, so closing this one. The branch is unchanged and still backs the upstream pull request. |
Which issue does this PR close?
No existing issue. We found this during an investigation of a production out of memory. We can file one if you want a changelog entry.
Rationale for this change
Reproduce it
Run this in
datafusion-cli. It writes 4,000,000 rows in 500,000 groups, with 2,000,000 distinct(g, x)pairs.On
mainthe rule leaves the aggregate alone:Now put the
CREATE EXTERNAL TABLEstatement and the query intorun.sql, and watch the process:On
mainthis machine reports 16.4 GiB peak RSS and 4.8 s for 500,000 output rows. Thecount(*)is the only reason the rule stops. Delete it andmainrewrites the query.What this PR changes for that query
The rule now accepts the
count(*)and rewrites the aggregate:The same run reports 335 MiB peak RSS and 0.07 s. The results are identical.
Read that 50x with care. It is the number on
maintoday, andmaindoes not carry apache#24857. apache#24857 removes a per-group pre-allocation of about 33.8 KiB from the path this rewrite exists to avoid. Stacked on apache#24857 the same comparison gives 1.82x, not 50x. The table below gives both columns.datafusion.execution.parquet.schema_force_view_typesdefaults to true, soxabove arrives asUtf8View.Why the rewrite helps here
SingleDistinctToGroupByrewritesAGG(DISTINCT x)into a two phase group by. The rule accepts a non-distinctsum,minormaxnext to the distinct aggregate. It rejects a non-distinctcount. Onecount(*)is therefore enough to keep the unrewritten plan.We hit this in production. A query of this shape drove a process to 10.98 GB and to death.
The rewrite is not free. Every other aggregate moves down into the inner group by. That group by holds one row for each
(group, distinct value)pair, not one row for each group. Each aggregate then keeps its state at that finer grain. The rewrite pays for this cost when it takes the distinct aggregate offGroupsAccumulatorAdapter. The adapter keeps one boxedAccumulatorfor each group, which is the expensive shape.count(DISTINCT x)has a specializedGroupsAccumulatorfor each integer type, and for no other type. An integer distinct count never reaches the adapter, so the rewrite buys nothing for it.The measurements
The harness reports the peak
MemoryPoolreservation forSELECT g, count(*), count(DISTINCT x) FROM t GROUP BY gover 4,000,000 rows. The first column is today'smain. The second column is the same measurement on a tree that also carries apache#24857.mainBIGINTUtf8Utf8ViewBIGINTUtf8Utf8ViewThe second column is the honest one, and it is much smaller than the first. The absolute saving in the motivating cell falls from 13.4 GiB to 181 MiB, which is about 75 times less. An earlier version of this description claimed 55x. That number is an artifact of a pre-allocation that goes away when apache#24857 lands.
Two notes on the table.
The type labels in the earlier description were wrong. A string column that comes from a Parquet file is
Utf8View, notUtf8, so the rows labelledVARCHARwereUtf8Viewrows. The two types are far apart onmain. At 500,000 groups the unrewritten arm costs 4.00 GiB forUtf8and 13.67 GiB forUtf8View.apache#24857 also closes the hole at 2,000 groups, and for an unexpected reason. The unrewritten arm gets larger there, from 261 MiB to 303 MiB. apache#24857 removes the pre-allocation, and it also makes
size()report the real hashbrown allocation. At 2,000 distinct values per group the honest accounting is larger than the pre-allocation it removes.Where the rewrite stops paying
There is no crossover in group count for a string argument. The peak of the rewritten arm follows the number of distinct pairs, which does not change with the group count. The peak of the unrewritten arm grows with the group count.
The crossover is in the density of distinct values. It arrives only when every row holds a distinct value. There the residual
Utf8loss is 1.06x to 1.08x, andUtf8Viewdoes not cross at all.The gate is a proxy
The gate asks which accumulator the distinct aggregate gets. That is not the true discriminator.
What decides the outcome is the cost per distinct value on each side. The rewrite materializes one hash table row for each distinct
(group keys, x)pair, which is about 48 bytes, plus one accumulator slot for each companion aggregate at that grain. The rewrite wins when the unrewritten accumulator costs more than that for each value. It loses when the accumulator costs less.The proxy and the true discriminator agree for
count(DISTINCT x), which is the only case this PR opens. They disagree elsewhere, and the pre-existing arms of this rule carry that disagreement:sum(DISTINCT int_col)andavg(DISTINCT int_col)have no distinct groups accumulator. They still regress 3.15x, at 71.0 MiB unrewritten against 223.4 MiB rewritten, over 4,000,000 rows in 2,000 groups.min(DISTINCT x)is worse.min(DISTINCT x)is the same value asmin(x), andmin_maxcorrectly ignoresis_distinct. The unrewritten plan holds one scalar for each group, and the rewrite builds a 4,000,000 row hash table. We measured up to 1113x more peak memory.That regression predates this PR, and this PR does not extend it. The gate keeps every one of those functions out of the path this PR adds. We report the broader regression upstream separately. A cost model is out of scope here.
What changes are included in this PR?
This PR allows a non-distinct
countnext to the distinct aggregate, when the distinct aggregate reports that it has no specializedGroupsAccumulatorfor its argument types.countis the one supported function whose outer phase is a different function. The inner group by counts the rows of each(group, distinct value)partition. The outer phase adds those partial counts withsum, because acountover a group is the sum of the counts of any partition of that group.Two details follow from that substitution.
countandsumcome from the session function registry, asreplace_distinct_aggregatealready does forfirst_value. The rewrite fires only for that exactcount, compared by identity and not by name. A session without a registry, or with its owncount, keeps the previous behaviour.countreturns a non-null0over an empty input, andsumof no rows returns NULL. An aggregate without aGROUP BYreaches that case. The inner aggregate emits no rows and the outer aggregate still emits one row, soSELECT count(*), count(DISTINCT x) FROM emptywould returnNULL, 0instead of0, 0. The projection selectsCASE WHEN sum(alias) IS NOT NULL THEN sum(alias) ELSE 0 END. That restores the0, and it keeps the type and the nullability thatcounthad.FILTERandORDER BYstill block the rewrite.The gate
An optimizer rule has no
AccumulatorArgsto askAggregateUDFImpl::groups_accumulator_supportedwith.datafusion-optimizeralso cannot depend ondatafusion-functions-aggregateto readcount's list of types.datafusion/optimizer/Cargo.tomlnames the intended way out:So this PR adds
AggregateUDFImpl::groups_accumulator_supported_for_types(&[DataType], is_distinct) -> Option<bool>.Countis the only implementor.Count::groups_accumulator_supportednow delegates to it, so there is one list of supported types and not two that can drift.The default is
None, which means the implementation does not answer the question.Noneis not a third answer, and a caller must not read it as eitherSome(true)orSome(false). This rule rewrites only onSome(false), which is the one answer that reports a call on the adapter. An aggregate that answersNone, which today is every aggregate exceptcount, keeps the previous behaviour.The gate covers only the
countthis PR adds. A plan that already qualifies through a non-distinctsum,minormaxis rewritten as before, over any distinct argument type. A narrower rule there would change plans that this repository has always rewritten, and nothing measured here calls for that change.Files touched beyond the rule itself:
datafusion/expr/src/udaf.rsanddatafusion/functions-aggregate/src/count.rs: the new trait method and its one implementation.datafusion/sqllogictest/test_files/single_distinct_to_groupby.slt: the new coverage described below.datafusion/substrait/tests/cases/roundtrip_logical_plan.rs:aggregate_distinct_with_havingnow builds its session without this rule, so it keeps round tripping the plan shape that the test was written for. Its distinct argument is aDECIMAL, so the gate does not exclude it.No existing snapshot in the repository changes.
What is the testing strategy for this PR?
single_distinct_to_groupby.sltasserts every result twice, once underdatafusion.optimizer.max_passes = 0and once under the default, with identical expected blocks. A null-handling error or a type error therefore appears as a result mismatch, and not only as a plan difference.The table carries the same values in a
VARCHARcolumn and in anINTcolumn, which are the two sides of the gate. The file asserts both sides. TheVARCHARdistinct rewrites with acountnext to it. TheINTdistinct does not. TheINTdistinct still rewrites when it qualifies throughsum. The results agree with the unoptimized plan in every case.The file also asserts that an aggregate which answers
Nonestays unrewritten.sum(DISTINCT v)andmin(DISTINCT v)next to acount(*)both keep the plan they have onmain.The remaining coverage is
count(*)againstcount(1)againstcount(col), grouped and ungrouped, a group whose distinct column is entirely NULL, a group with NULLs in both the distinct column and the summed column, empty input in three shapes,HAVINGwithORDER BYon the rewritten count, and the production join shape.The unit tests of the rule cover both sides of the gate directly. They also cover the
Noneanswer twice: once withsum(DISTINCT b), and once with a test aggregate that leaves the new method at its default.Benchmarks
Q22 is the only ClickBench query whose plan this PR can change, and under the gate it no longer does. Its
count(DISTINCT "UserID")is over anInt64. Q9 (RegionID, SUM, COUNT(*), AVG, COUNT(DISTINCT UserID)) still bails out, becauseAVGdisqualifies it. Q8, Q10, Q11 and Q13 have a lone distinct aggregate andmainalready rewrites them. The gate does not touch them.Latency. Measured on
clickbench_partitioned(100 files, about 100M rows) before the gate, when Q22 did change. A run-level A/B cannot resolve a change this small. A comparison of the base binary against itself withcompare.pyreported 9 queries faster, 28 slower and 6 unchanged, with swings up to 1.58x. A paired comparison over 40 repetitions, with the 42 unchanged queries as an in-experiment control, put Q22 at-2.03%, 95% CI[-5.48%, +1.39%]. There is no measurable latency difference, and this setup resolves effects of about 3% and no better. That agrees with apache#11360, which found the removal of the whole rule to be a wash.Memory. A memory-limited run (
DATAFUSION_RUNTIME_MEMORY_LIMIT: 4G) of the ungated version produced the gate. The peakMemoryPoolreservation of Q22 went from 3.1 MiB to 7.3 MiB, which is+132.2%, and Q22 was the only plan that changed. That is the integer case above. The base plan already hadPrimitiveDistinctCountGroupsAccumulator, and the rewrite replaced it with an inner group by on(SearchPhrase, UserID)that also carriesmin("URL")andmin("Title")at that grain.Re-benchmarked with the gate, same benchmark and same 4G limit, three independent runs from one trigger against the same merge base (1, 2, 3):
The
+132.2%is gone. The plan of Q22 is now the base plan byte for byte, so the two sides differ only by measurement noise. Do not read any of it as an improvement. The noise on a 3 MiB high-water mark reported to 0.1 MiB is large, and the base side alone spans 3.0 MiB to 4.8 MiB across three runs of identical code. For scale, Q17 (GROUP BY "UserID", "SearchPhrase", noDISTINCT, so this PR cannot change its plan) moved +6.3%, +26.7% and +6.9% on a 2 GiB reservation in those same three runs. No query moved consistently in the regressing direction.Those runs are on
d510dd4. The commits after it change a doc comment, the treatment of an unanswered gate question, and tests. No ClickBench query holds a distinctsum,min,maxoravg, so none of those commits can change a ClickBench plan.Are there any user-facing changes?
There is no public API change and no change to query results.
AggregateUDFImplgains one method with a default, which is not a breaking change for implementors.Plans for
SELECT ..., count(...), count(DISTINCT x) ... GROUP BY ...change shape whenxhas no specializedGroupsAccumulator.EXPLAINoutput for that shape therefore differs, and such queries should use less memory. Read the size of that win from the second column of the table above, and not from the first.The size of the win also depends on the group count, because the cost of the adapter is per group. The rule has always had that property. This PR does not change it, and the optimizer has no group cardinality estimate to gate on.