Skip to content

Aggregates with FILTER (WHERE ...) copy every input column once per aggregate #27114

Closed

Description

@xudong963

Is your feature request related to a problem or challenge?

An aggregate with a FILTER clause evaluates its arguments only on the rows that the filter selects, so that SUM(1 / x) FILTER (WHERE x <> 0) cannot fail on the rejected rows (#24443). The selected rows are produced by filtering the whole input batch of the aggregate, once per filtered aggregate:

The input batch of an aggregate holds the group keys and every column read by any of its aggregates, while one aggregate usually reads one or two of them. The copying therefore grows with (number of filtered aggregates) × (number of input columns), and queries with many filtered aggregates spend most of their aggregation time on it. Typical shapes are pivot-style reports and scalar subqueries merged into one aggregate (TPC-DS q9, as Spark's MergeScalarSubqueries does).

Reproducer with datafusion-cli built from main (70f2ed0):

-- 20M rows
COPY (
  SELECT
    value % 100 + 1 AS quantity,
    arrow_cast((value % 9973) / 100.0, 'Decimal128(7, 2)') AS ext_tax,
    arrow_cast((value % 99991) / 10.0, 'Decimal128(7, 2)') AS net_paid
  FROM generate_series(0, 19999999)
) TO 'sales.parquet' STORED AS PARQUET;

CREATE EXTERNAL TABLE sales STORED AS PARQUET LOCATION 'sales.parquet';

-- TPC-DS q9 shape: 15 filtered aggregates over 3 columns
SELECT
  count(*) FILTER (WHERE quantity BETWEEN 1 AND 20) AS c1,
  avg(ext_tax) FILTER (WHERE quantity BETWEEN 1 AND 20) AS t1,
  avg(net_paid) FILTER (WHERE quantity BETWEEN 1 AND 20) AS p1,
  count(*) FILTER (WHERE quantity BETWEEN 21 AND 40) AS c2,
  avg(ext_tax) FILTER (WHERE quantity BETWEEN 21 AND 40) AS t2,
  avg(net_paid) FILTER (WHERE quantity BETWEEN 21 AND 40) AS p2,
  count(*) FILTER (WHERE quantity BETWEEN 41 AND 60) AS c3,
  avg(ext_tax) FILTER (WHERE quantity BETWEEN 41 AND 60) AS t3,
  avg(net_paid) FILTER (WHERE quantity BETWEEN 41 AND 60) AS p3,
  count(*) FILTER (WHERE quantity BETWEEN 61 AND 80) AS c4,
  avg(ext_tax) FILTER (WHERE quantity BETWEEN 61 AND 80) AS t4,
  avg(net_paid) FILTER (WHERE quantity BETWEEN 61 AND 80) AS p4,
  count(*) FILTER (WHERE quantity BETWEEN 81 AND 100) AS c5,
  avg(ext_tax) FILTER (WHERE quantity BETWEEN 81 AND 100) AS t5,
  avg(net_paid) FILTER (WHERE quantity BETWEEN 81 AND 100) AS p5
FROM sales;

Apple M4 Pro (12 cores), median of 21 warm runs. The second column is the same query with each agg(x) FILTER (WHERE c) written as agg(CASE WHEN c THEN x END) (and count(*) as count(CASE WHEN c THEN 1 END)), which gives identical results:

Query FILTER CASE WHEN
query above 50 ms 38 ms
same, GROUP BY quantity % 8 68 ms 49 ms

EXPLAIN ANALYZE shows where the time goes. In the mode=Partial AggregateExec of the query above, the agg_expr_*_arguments_time metrics add up to 207–236 ms of its 219–255 ms elapsed_compute.

Describe the solution you'd like

Filter only the columns that the aggregate's arguments read, including the ORDER BY expressions of ordered aggregates such as first_value(x ORDER BY y). The other columns can be replaced by data-free NullArray placeholders, so column indices in the argument expressions stay valid, and arguments are still evaluated only on the selected rows. The skip-partial path can use the same filtered batch, once per aggregate instead of once per argument.

With a local prototype of this, the results are identical:

Query main prototype
query above 50 ms 28 ms (1.8x)
same, GROUP BY quantity % 8 68 ms 43 ms (1.6x)
arguments_time sum, query above 207–236 ms 37–39 ms
CASE WHEN variants (no FILTER) 38 / 49 ms 38 / 49 ms

On TPC-DS SF1, q9 with its 15 scalar subqueries merged by hand into one aggregate like the query above went from 93 ms to 25 ms (measured on an earlier main, 290ad15).

When grouping dominates, the copy is a small share of the work: with 1.7M groups from 20M rows, the prototype made no measurable difference.

Describe alternatives you've considered

  • Evaluating arguments on all rows and passing the filter to the accumulator, as GroupedHashAggregateStream (enable_migration_aggregate = false) still does. It copies nothing but brings back Grouped aggregate FILTER evaluates arguments for rejected rows #24443.
  • Rewriting FILTER into CASE WHEN in the optimizer. That is only equivalent for aggregates that ignore NULLs (not count(*), first_value, array_agg, ...), and it is still slower than filtering only the needed columns.

Additional context

cc @lyne7-sc, who wrote #24444 and #24869.

Willingness to contribute

I plan to contribute to this feature independently.

Activity

  1. self-assigned this
    on Nov 5, 2026
  2. added
    regressionSomething that used to work no longer does
    on Nov 9, 2026
  3. alamb commented on Nov 9, 2026

    @alamb
    Contributor

    Marking as a regression per comment here

  4. added a commit that references this issue on Nov 11, 2026
    34eb6dc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestperformanceMake DataFusion fasterregressionSomething that used to work no longer does

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions