Skip to content

fix: skip count-distinct byte tests under force_hash_collisions - #25020

Merged
kosiew merged 1 commit into
apache:mainfrom
DevShiba:fix/skip-count-distinct-bytes-tests-under-forced-collisions
Sep 7, 2026
Merged

fix: skip count-distinct byte tests under force_hash_collisions#25020
kosiew merged 1 commit into
apache:mainfrom
DevShiba:fix/skip-count-distinct-bytes-tests-under-forced-collisions

Conversation

@DevShiba

@DevShiba DevShiba commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

The cargo test hash collisions (amd64) CI job hangs for hours (sometimes hitting the 360-minute job limit and getting cancelled) in two tests in datafusion/functions-aggregate-common/src/aggregate/count_distinct/bytes.rs:

aggregate::count_distinct::bytes::tests::ungrouped_utf8_accumulator_is_never_worse_than_a_pre_allocated_set
aggregate::count_distinct::bytes::tests::ungrouped_utf8_view_accumulator_is_never_worse_than_a_pre_allocated_set

Root cause: both tests insert up to 500,000 distinct values into ArrowBytesSet/ArrowBytesViewSet, twice per cardinality in CARDINALITIES (once into a lazily-constructed set, once into a pre-allocated one), to compare their reported .size(). Under normal hashing this is O(n) per insert. Under force_hash_collisions (datafusion/common/src/hash_utils.rs#L1184-L1195) every value hashes to the same bucket, so the underlying hash table degrades to a linear scan per insert - O(n^2) overall for a set built up to n elements.

I confirmed this is quadratic, not just slow, with a throwaway local timing probe over the same insert pattern under --features datafusion-common/force_hash_collisions (removed before this PR, shown here for reference):

n time
100 297us
500 3.7ms
1,000 14ms
2,000 54ms
5,000 347ms

Each 2x step in n is roughly a 4x step in time, consistent with O(n^2), and consistent with the multi-hour runtimes reported in #25011 for n up to 500,000.

On the question raised in #25011 ("what behavior or regression boundaries are the 100,000 and 500,000 cardinalities intended to protect, and what approach would preserve that coverage?"): the assertions in assert_lazy_is_not_worse compare allocator sizes reported by a real hash-table implementation against a pre-allocated one, at cardinalities chosen to span both sides of the warm-up capacity (PER_GROUP_SCALE) and the point where the two constructors converge (UNGROUPED_SCALE). None of that is about hash collision behavior - forcing every key into one bucket doesn't exercise a code path these tests are meant to protect, it just makes every insert scan the one bucket's full contents, which is why the cost goes quadratic without adding coverage.

This is the same situation the force_hash_collisions feature already has an established answer for: count_distinct_spill in datafusion/core/tests/memory_limit/mod.rs (added in #24918) is gated with #[cfg(not(feature = "force_hash_collisions"))] because its assertions depend on a real hash distribution across partitions and don't mean anything under forced collisions. This PR applies the identical pattern here, rather than reducing cardinality or otherwise changing what real-hashing runs cover.

What changes are included in this PR?

  • datafusion/functions-aggregate-common/Cargo.toml: add a [features] section declaring force_hash_collisions = ["datafusion-common/force_hash_collisions"], forwarding to datafusion-common's feature of the same name. This crate previously declared no features of its own. Cargo does not propagate a dependency's active feature into a consuming crate's own cfg(feature = ...) checks, so without this forwarding declaration, a #[cfg(feature = "force_hash_collisions")] inside this crate would never see the workspace-level --features force_hash_collisions flag the affected CI job passes (cargo test --workspace --features=force_hash_collisions,avro). This mirrors the exact forwarding pattern already used in datafusion/core/Cargo.toml for the same feature name.
  • datafusion/functions-aggregate-common/src/aggregate/count_distinct/bytes.rs: gate the whole mod tests block (it contains only these two tests and their shared helpers - nothing else needs to stay compiled either way) with #[cfg(all(test, not(feature = "force_hash_collisions")))], with a doc comment explaining the O(n^2) mechanism and linking back to this issue.

No production code changes. No reduction in cardinality or coverage for the normal (non-collision-forced) test run - both tests still run exactly as before, across all 7 cardinalities up to 500,000, whenever force_hash_collisions is off.

What is the testing strategy for this PR?

This is a test-only change, verified by running the tests both ways:

  • Without the feature: cargo test -p datafusion-functions-aggregate-common --lib -- count_distinct::bytes still runs and passes both tests in ~0.8s.
  • With the feature: cargo test -p datafusion-functions-aggregate-common --lib --features force_hash_collisions -- count_distinct::bytes runs 0 tests with a clean compile - confirming the gate compiles out cleanly rather than silently failing to match.
  • Against the exact affected CI job command (cd datafusion && cargo test --profile ci --exclude datafusion-examples --exclude datafusion-benchmarks --exclude datafusion-sqllogictest --exclude datafusion-cli --workspace --lib --tests --features=force_hash_collisions,avro): the datafusion-functions-aggregate-common test binary reports 47 tests (49 minus the 2 gated ones) all passing, with neither ungrouped_utf8_accumulator_is_never_worse_than_a_pre_allocated_set nor its Utf8View counterpart appearing anywhere in the run - confirming the workspace-level feature flag correctly reaches the new local feature via Cargo's feature unification, not just the crate-local invocation.
  • cargo fmt --check and the exact CI clippy invocation (ci/scripts/rust_clippy.sh, i.e. cargo clippy --all-targets --workspace --features avro,integration-tests,extended_tests -- -D warnings) both pass clean across the whole workspace.

Are there any user-facing changes?

None. This only changes which tests compile under a testing-only feature flag; there is no change to any public API or runtime behavior.

`ungrouped_utf8_accumulator_is_never_worse_than_a_pre_allocated_set`
and its `Utf8View` counterpart in
`datafusion/functions-aggregate-common/src/aggregate/count_distinct/bytes.rs`
insert up to 500,000 distinct values into `ArrowBytesSet`/`ArrowBytesViewSet`
twice per cardinality (once lazily built, once pre-allocated). Under normal
hashing this is O(n) per insert. Under `force_hash_collisions` every value
hashes to the same bucket, degrading the set to a linear scan per insert,
i.e. O(n^2) overall.

Empirically measured locally with a throwaway timing probe (since removed):

    n=100    ->  297us
    n=500    ->  3.7ms
    n=1000   ->  14ms
    n=2000   ->  54ms
    n=5000   -> 347ms

which is consistent with the quadratic growth reported in apache#25011 (some CI
runs completing in ~2h39m/4h31m for the two tests, others exceeding the
360-minute job limit and getting cancelled).

This mirrors the existing `force_hash_collisions` precedent for exactly
this class of problem: `count_distinct_spill` in
`datafusion/core/tests/memory_limit/mod.rs` (added in apache#24918) is gated with
`#[cfg(not(feature = "force_hash_collisions"))]` because its assertions
depend on a real hash distribution across partitions. The same reasoning
applies here — these tests assert on allocator sizes that only make sense
under real hashing, and forcing every key into one bucket does not exercise
any behavior the test is meant to protect, it only inflates the runtime.

Changes:
- `datafusion/functions-aggregate-common/Cargo.toml`: declare a local
  `force_hash_collisions` feature forwarding to
  `datafusion-common/force_hash_collisions`, matching the same forwarding
  pattern used in `datafusion/core/Cargo.toml`. Needed because Cargo does
  not propagate a dependency's active feature into a consuming crate's own
  `cfg(feature = ...)` checks - the crate must declare (and forward) the
  feature itself for its own `#[cfg(feature = "force_hash_collisions")]` to
  respond to the workspace-level `--features force_hash_collisions` flag
  the affected CI job passes.
- `bytes.rs`: gate the whole `mod tests` block with
  `#[cfg(all(test, not(feature = "force_hash_collisions")))]`, since it
  contains only these two tests and their shared helpers.

No production code changes; no reduction in cardinality or coverage under
normal (non-collision-forced) test runs, where both tests still run exactly
as before across all 7 cardinalities up to 500,000.

Verified:
- `cargo test -p datafusion-functions-aggregate-common --lib -- count_distinct::bytes`
  (feature off): both tests still run and pass, 0.75s.
- `cargo test -p datafusion-functions-aggregate-common --lib --features force_hash_collisions -- count_distinct::bytes`
  (crate-local feature on): 0 tests run, clean compile.
- The exact affected CI job command, `cargo test --profile ci --exclude
  datafusion-examples --exclude datafusion-benchmarks --exclude
  datafusion-sqllogictest --exclude datafusion-cli --workspace --lib --tests
  --features=force_hash_collisions,avro`: the crate's test binary reports
  47 tests (49 minus the 2 gated ones), all passing, with neither
  `ungrouped_utf8_accumulator_is_never_worse_than_a_pre_allocated_set` nor
  its view counterpart appearing in the run.
- `cargo fmt --check` and the exact `ci/scripts/rust_clippy.sh`
  (`cargo clippy --all-targets --workspace --features
  avro,integration-tests,extended_tests -- -D warnings`): both clean.

Closes apache#25011
@github-actions github-actions Bot added the functions Changes to functions implementation label Sep 7, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.71%. Comparing base (13ea24a) to head (688f058).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25020      +/-   ##
==========================================
- Coverage   81.71%   81.71%   -0.01%     
==========================================
  Files        1127     1127              
  Lines      416051   416051              
  Branches   416051   416051              
==========================================
- Hits       339974   339962      -12     
- Misses      56091    56097       +6     
- Partials    19986    19992       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@adriangb
adriangb added this pull request to the merge queue Sep 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 7, 2026
@kosiew
kosiew added this pull request to the merge queue Sep 7, 2026
Merged via the queue into apache:main with commit 6ab4ce6 Sep 7, 2026
39 checks passed
alamb pushed a commit to comphead/arrow-datafusion that referenced this pull request Sep 10, 2026
… SingleDistinctToGroupBy (apache#24859)

## Which issue does this PR close?

No existing issue. We found this while investigating an out-of-memory.
Happy to file one if you want a changelog entry.

## Rationale for this change

### The query

```sql
SELECT g, count(*), count(DISTINCT x) FROM t GROUP BY g;
```

A grouped `count(DISTINCT <string>)` next to a plain `count(*)`. It is a
very common shape, and on `main` it uses several times more memory than
it needs to.

To reproduce, in `datafusion-cli`. This writes 4,000,000 rows in 500,000
groups, with 2,000,000 distinct `(g, x)` pairs:

```sql
COPY (
  SELECT
    value % 500000 AS g,
    'id-' || CAST(value % 2000000 AS VARCHAR) AS x
  FROM generate_series(1, 4000000)
) TO 'repro.parquet' STORED AS PARQUET;

CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION 'repro.parquet';

EXPLAIN FORMAT INDENT SELECT g, count(*), count(DISTINCT x) FROM t GROUP BY g;
```

### The plan today

```text
Projection: t.g, count(Int64(1)) AS count(*), count(DISTINCT t.x)
  Aggregate: groupBy=[[t.g]], aggr=[[count(Int64(1)), count(DISTINCT t.x)]]
    TableScan: t projection=[g, x]
```

A single `Aggregate` that computes the distinct count directly.
`count(DISTINCT x)` has a specialized `GroupsAccumulator` for the
integer types and for no others, so over a string it falls back to
`GroupsAccumulatorAdapter`, which holds one boxed `Accumulator` — and
therefore one hash table — **for every group**. At 500,000 groups that
is 500,000 hash tables.

There is a second effect once there is more than one partition. This
plan's partial aggregate sees every group in every partition, so those
per-group accumulators are duplicated `target_partitions` times.

### What already exists

DataFusion has a rule for exactly this, `SingleDistinctToGroupBy`. It
rewrites `AGG(DISTINCT x)` into a two phase group by: an inner aggregate
that groups by `(group keys, x)`, so the distinct-ing is done by the
hash table that group by already builds, and an outer aggregate over its
output. One hash table instead of one per group, and it hash-partitions,
so distinct values are split across partitions rather than replicated.

The rule accepts a non-distinct `sum`, `min` or `max` alongside the
distinct aggregate. **It rejects a non-distinct `count`.** So a single
`count(*)` is enough to keep the slow plan.

### The change

Allow that `count`.

`count` is the one supported companion whose outer phase must be a
*different* function. The inner group by counts the rows of each
`(group, distinct value)` partition; the outer phase adds those partial
counts with `sum`, because a count over a group is the sum of the counts
of any partition of that group.

### The plan after

```text
Projection: t.g, CASE WHEN sum(alias2) IS NOT NULL THEN sum(alias2) ELSE Int64(0) END AS count(*), count(alias1) AS count(DISTINCT t.x)
  Aggregate: groupBy=[[t.g]], aggr=[[sum(alias2), count(alias1)]]
    Aggregate: groupBy=[[t.g, t.x AS alias1]], aggr=[[count(Int64(1)) AS alias2]]
      TableScan: t projection=[g, x]
```

Results are identical. The `CASE` is the one place the two phases
disagree: over an empty input the inner group by emits no rows, and
`sum` of no rows is NULL where `count` is `0`. Restoring the `0` also
preserves `count`'s non-nullability.

### When this is allowed

The rewrite is not free. Every other aggregate moves down into the inner
group by, which holds a row per `(group, distinct value)` pair rather
than per group, and keeps its state at that finer grain. What pays for
that is taking the distinct aggregate off the adapter — so if the
distinct aggregate was never on the adapter, there is nothing to buy and
only the inner group by to pay for.

So the new `count` is gated on the distinct aggregate reporting that it
has **no** specialized `GroupsAccumulator` for its argument types.
ClickBench q22 is the real case: its `count(DISTINCT "UserID")` is over
an `Int64`, which has one, so q22 keeps its current plan.

## What changes are included in this PR?

Five files. The rule, the gate it needs, and tests.

### The rule

`datafusion/optimizer/src/single_distinct_to_groupby.rs` accepts a
non-distinct `count` as a companion, and gives it `sum` as its outer
phase.

`count` and `sum` are resolved from the session function registry, as
`replace_distinct_aggregate` already does for `first_value`. The rewrite
fires only for that exact `count`, compared by identity rather than by
name, so a session with its own `count`, or with no registry, keeps the
previous behaviour.

`FILTER` and `ORDER BY` still block the rewrite.

### The gate

An optimizer rule has no `AccumulatorArgs` to call
`AggregateUDFImpl::groups_accumulator_supported` with, and
`datafusion-optimizer` cannot depend on `datafusion-functions-aggregate`
to read `count`'s type list. `datafusion/optimizer/Cargo.toml` names the
intended way out:

> If you want to add special handling for a specific function, use the
methods on the `ScalarUDFImpl` or `AggregateUDFImpl` traits (or add a
new method to those traits).

So this adds one trait method:

```rust
fn groups_accumulator_supported_for_types(
    &self,
    arg_types: &[DataType],
    is_distinct: bool,
) -> Option<bool> { None }
```

`Count` is the only implementor, and
`Count::groups_accumulator_supported` now delegates to it, so there is
one list of supported types rather than two that can drift.

The default `None` means *the implementation does not answer this
question*. It is not a third answer: a caller must not read it as either
`Some(true)` or `Some(false)`. This rule rewrites only on `Some(false)`,
the one answer that positively reports a call on the adapter. Every
aggregate except `count` answers `None` today and so keeps its current
behaviour.

The gate covers only the `count` this PR adds. A plan that already
qualifies through a non-distinct `sum`, `min` or `max` is rewritten as
before, over any distinct argument type — see [Pre-existing
regressions](#pre-existing-regressions-not-introduced-here).

### Tests

- `datafusion/sqllogictest/test_files/single_distinct_to_groupby.slt`,
new.
- `datafusion/substrait/tests/cases/roundtrip_logical_plan.rs`:
`aggregate_distinct_with_having` builds its session without this rule,
so it keeps round tripping the plan shape it was written for; a
companion test covers the rewritten plan's schema and results through
Substrait. (The rule's aliases have no Substrait representation, so a
rewritten plan does not round trip to an *identical* plan.)

**No existing snapshot in the repository changes.**

## What is the testing strategy for this PR?

`single_distinct_to_groupby.slt` asserts every result **twice** — once
under `datafusion.optimizer.max_passes = 0` and once under the default —
with identical expected blocks. A null-handling or type error therefore
shows up as a result mismatch, not only as a plan difference.

The table carries the same values in a `VARCHAR` column and an `INT`
column, which are the two sides of the gate, and the file asserts both:
the `VARCHAR` distinct rewrites with a `count` beside it, the `INT`
distinct does not, and the `INT` distinct still rewrites when it
qualifies through `sum`.

It also asserts that an aggregate answering `None` stays unrewritten
(`sum(DISTINCT v)` and `min(DISTINCT v)` beside a `count(*)` keep the
plan they have on `main`).

Remaining coverage: `count(*)` vs `count(1)` vs `count(col)`, grouped
and ungrouped; a group whose distinct column is entirely NULL; NULLs in
both the distinct and the summed column; empty input in three shapes;
`HAVING` with `ORDER BY` on the rewritten count; and the same aggregates
over a join.

The rule's unit tests cover both sides of the gate directly, and the
`None` answer twice — once with `sum(DISTINCT b)`, once with a test
aggregate that leaves the new method at its default.

Run locally on the rebased head, all passing: `datafusion-optimizer`,
`datafusion-expr` and `datafusion-functions-aggregate` lib and
integration tests, the substrait roundtrip suite, and the whole
sqllogictest suite at 511 of 511 files.

## Benchmarks

### Macro: ClickBench

`clickbench_extended` q14 is the one query in any suite with this shape
— a lone `COUNT(DISTINCT <string>)` next to a non-distinct `COUNT(*)`,
grouped by a high cardinality string. It landed on `main` in apache#25026.
Three runs of this branch against merge base `16ace4f`,
`DATAFUSION_RUNTIME_MEMORY_LIMIT: 16G`
([trigger](apache#24859 (comment))):

| run | q14 fastest | q14 median of 5 | q14 peak pool |
| --- | --- | --- | --- |
| 1 | 2471 ms → 546 ms, **4.53x** | 4.05x | 4.4 GiB → 1.3 GiB,
**-70.5%** |
| 2 | 2534 ms → 575 ms, **4.40x** | 4.15x | 4.4 GiB → 1.3 GiB,
**-69.7%** |
| 3 | 3909 ms → 1165 ms, **3.36x** | 3.39x | 4.4 GiB → 1.3 GiB,
**-70.3%** |

100,000,000 rows of real ClickBench data. The memory figure reproduces
to a tenth of a percent across the three runs.

The limit is 16G because the base side peaks at 4.4 GiB; at 4G the
baseline OOMs and there is nothing to compare.

Run 3 was on a contended machine — its suite total is 42.1 s against
30.8 s and 31.0 s — and it reports q0 at 1.30x slower, q7 at 1.14x and
q8 at 1.13x. None of those three can change plan here (q0 has three
distinct arguments; q7 and q8 have no distinct aggregate at all), so
they are an in-experiment control that says what that run's noise floor
was.

#### The standard suite, and q22

`q22` is the only query in the standard suite that reaches the new gate,
and the gate excludes it. A `clickbench_partitioned` run
([trigger](apache#24859 (comment)),
`DATAFUSION_RUNTIME_MEMORY_LIMIT: 4G`):

| | base `16ace4f` | this branch | |
| --- | --- | --- | --- |
| q22 wall clock | 975.75 ms | 976.75 ms | no change |
| q22 peak pool | 2.5 MiB | 3.0 MiB | +22.0% |
| suite total | 25985 ms | 26073 ms | +0.3% |
| queries changed | — | — | **0 of 43** |

The `+22.0%` is measurement noise on a 2.5 MiB reservation, not an
effect of this PR. Three things say so:

1. **The plan is unchanged.** `EXPLAIN` on q22's exact shape against
this branch produces a single `Aggregate` — the rule does not fire,
because `COUNT(DISTINCT "UserID")` is over an `Int64`, which has a
specialized `GroupsAccumulator`:
   ```text
   Sort: c DESC NULLS FIRST, fetch=10
Projection: hits.SearchPhrase, min(hits.URL), min(hits.Title),
count(Int64(1)) AS count(*) AS c, count(DISTINCT hits.UserID)
Aggregate: groupBy=[[hits.SearchPhrase]], aggr=[[min(hits.URL),
min(hits.Title), count(Int64(1)), count(DISTINCT hits.UserID)]]
         Filter: ...
           TableScan: hits projection=[SearchPhrase, URL, Title, UserID]
   ```
2. **The wall clock is flat** at 0.1%.
3. **The run's memory noise floor is wider than the reading.** 9 of the
43 queries move by more than 5% in both directions, including q23 at
**-26.1%** and q7 at **-19.7%** — neither of which can change plan under
this rule.

**No other query changes plan, and that set is empty by construction,
not by measurement.** The rule needs exactly one distinct argument and
accepts only `sum`, `min`, `max` and now `count` as companions:

| query | why it cannot move |
| --- | --- |
| `extended` q0, q1, q2 | three, three and four distinct arguments, so
`fields_set.len() != 1` |
| q4, q5, q8, q10, q11, q13 | a lone distinct aggregate with no
non-distinct companion, so `main` already rewrites them |
| q9 | carries an `AVG`, which the rule has never accepted |
| q22 | the only standard query that reaches the new gate, and its
`COUNT(DISTINCT "UserID")` is over an `Int64`, which has a specialized
`GroupsAccumulator` |

### Micro: the reproduction above, swept

Base is `e1ca94fb11`, 12 commits behind the `16ace4f` merge base used
for the ClickBench runs above; both are from the same day, and none of
the 12 changes aggregate runtime behaviour (the only one touching
`count_distinct` is apache#25020, which is test-gating only). Both sides are
release builds of the same harness: a `GreedyMemoryPool` of 64 GiB
(never reached) wrapped in the in-tree `PeakRecordingPool`; one parquet
file; the result streamed rather than collected; the pool high-water
mark and `getrusage` peak RSS reported. Every figure is the median of
three runs. At `target_partitions = 1` the three runs agreed to the byte
in every cell; at 8, to within 1%, except two branch cells at 7% and
19%.

Every shape is 4,000,000 rows, query `SELECT g, count(*), count(DISTINCT
x) FROM t GROUP BY g`. Each file carries the distinct argument three
times — as `Utf8`, `Utf8View` and `BIGINT` — written with an explicit
`arrow_cast` so the type is the file's and not a reader setting.

**`target_partitions = 8`**

| groups | distinct values | argument | peak pool | peak RSS | wall
clock |
| --- | --- | --- | --- | --- | --- |
| 500,000 | 2,000,000 | `Utf8` | 2.16x better | 2.56x better | 999 ms →
48 ms |
| 500,000 | 2,000,000 | `Utf8View` | 2.68x better | 2.76x better | 1036
ms → 40 ms |
| 500,000 | 1,000,000 | `Utf8` | 2.83x better | 3.40x better | 1020 ms →
41 ms |
| 500,000 | 1,000,000 | `Utf8View` | 3.15x better | 3.43x better | 966
ms → 34 ms |
| 10 | 2,000,000 | `Utf8` | 1.34x better | 1.85x better | 80 ms → 41 ms
|
| 10 | 2,000,000 | `Utf8View` | 1.87x better | 2.24x better | 73 ms → 33
ms |
| 1 | 2,000,000 | `Utf8` | 1.55x better | 2.14x better | 202 ms → 45 ms
|
| 1 | 2,000,000 | `Utf8View` | 1.88x better | 2.51x better | 158 ms → 34
ms |
| 2,000 | 4,000,000 | `Utf8` | **1.30x worse** | 2.03x better | 180 ms →
43 ms |
| 2,000 | 4,000,000 | `Utf8View` | **1.10x worse** | 2.55x better | 185
ms → 38 ms |
| 500,000 | 2,000,000 | `BIGINT` | 1.00x, plan unchanged | 1.01x | 43 ms
→ 43 ms |

**`target_partitions = 1`**

| groups | distinct values | argument | peak pool | peak RSS | wall
clock |
| --- | --- | --- | --- | --- | --- |
| 500,000 | 2,000,000 | `Utf8` | 2.18x better | 1.77x better | 712 ms →
193 ms |
| 500,000 | 2,000,000 | `Utf8View` | 2.70x better | 2.05x better | 684
ms → 178 ms |
| 500,000 | 1,000,000 | `Utf8` | 3.31x better | 1.93x better | 626 ms →
160 ms |
| 500,000 | 1,000,000 | `Utf8View` | 4.42x better | 2.34x better | 599
ms → 138 ms |
| 500,000 | 4,000,000 | `Utf8` | 1.61x better | 1.70x better | 825 ms →
242 ms |
| 500,000 | 4,000,000 | `Utf8View` | 1.96x better | 2.17x better | 755
ms → 232 ms |
| 2,000 | 2,000,000 | `Utf8` | 1.01x better | 1.41x better | 402 ms →
161 ms |
| 2,000 | 2,000,000 | `Utf8View` | 1.26x better | 1.49x better | 358 ms
→ 139 ms |
| 2,000 | 4,000,000 | `Utf8` | 1.01x better | 1.17x better | 429 ms →
203 ms |
| 2,000 | 4,000,000 | `Utf8View` | 1.26x better | 1.36x better | 407 ms
→ 176 ms |

Three things worth calling out:

- **Wall clock was not the point of this PR and is the largest effect.**
At 8 partitions and 500,000 groups the query goes from about a second to
about 40 ms. That is the accumulator-per-group construction cost, which
grows with partitions on the unrewritten side while the rewritten side
parallelizes.
- **Peak RSS improves in every measured shape**, including the two where
the pool peak regresses. The unrewritten plan's RSS sits far above what
it reserves; millions of small independent hash tables fragment in a way
one large table does not.
- **The `BIGINT` row is the control for the gate.** The plan is
identical on both sides, and so is every measurement. This is the q22
case, measured directly.

### Where the rewrite stops paying

The crossover is in the *density* of distinct values, not the group
count. It arrives only when nearly every row holds a distinct value, and
the loss is bounded: 1.30x more peak pool for `Utf8` and 1.10x for
`Utf8View`, at 2,000 groups over 4,000,000 fully distinct values at 8
partitions, and a wash at one partition. Those same two cells use about
half the RSS and run four times faster.

At 500,000 groups over 4,000,000 fully distinct values — the same
density — the rewrite is 1.61x to 1.96x better again, because the
unrewritten side now also pays for 500,000 accumulators.

### 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 per distinct `(group keys,
x)` pair, plus one accumulator slot per companion aggregate at that
grain. It wins when the unrewritten accumulator costs more than that per
value, and loses when it costs less.

Proxy and discriminator agree for `count(DISTINCT x)`, which is the only
case this PR opens. They disagree elsewhere.

#### Pre-existing regressions, not introduced here

Re-measured on the same base, over 4,000,000 rows in 2,000 groups with
2,000,000 distinct values, comparing the rewritten plan against the same
query with a `count(*)` added to hold the rule off:

- `sum(DISTINCT int_col)` and `avg(DISTINCT int_col)` have no distinct
groups accumulator, and `main` rewrites both today. Both regress:
**2.67x** more peak pool at one partition, 1.79x at eight.
- `min(DISTINCT x)` is far worse. It is the same value as `min(x)`, and
`min_max` correctly ignores `is_distinct`, so the unrewritten plan holds
one scalar per group while the rewrite builds a hash table over every
distinct pair: **543x** more peak pool at one partition, 126x at eight.

That regression predates this PR and this PR does not extend it — the
gate keeps every one of those functions out of the path added here.
Reported upstream separately. A cost model is out of scope.

## Are there any user-facing changes?

No public API break and no change to query results. `AggregateUDFImpl`
gains one method with a default, which is not breaking for implementors.

Plans for `SELECT ..., count(...), count(DISTINCT x) ... GROUP BY ...`
change shape when `x` has no specialized `GroupsAccumulator`. `EXPLAIN`
output for that shape therefore differs, and such queries should use
less memory and run faster.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Changes to functions implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: count-distinct byte tests run for hours and hit the 360-minute limit

4 participants