rkrishn7 opened a new pull request, #27150:
URL: https://github.com/apache/datafusion/pull/27150

   ## Which issue does this PR close?
   
   - Closes #27149
   
   ## Rationale for this change
   
   `first_value` only has a `GroupsAccumulator` when it has an `ORDER BY`. 
Without one, grouped aggregation uses `GroupsAccumulatorAdapter`, which keeps 
one boxed `TrivialFirstValueAccumulator`, holding a `ScalarValue`, per group.
   
   The planner produces unordered `first_value` for every `DISTINCT ON` without 
`ORDER BY`: `ReplaceDistinctWithAggregate` turns `SELECT DISTINCT ON (keys) 
cols` into an aggregate grouped by `keys` with one `first_value(col)` per 
selected column. With many distinct keys, the per-group accumulators dominate 
both time and memory.
   
   ## What changes are included in this PR?
   
   - `UnorderedFirstValueGroupsAccumulator`: a `GroupsAccumulator` for 
`first_value` without `ORDER BY`. It stores values in the existing `ValueState` 
implementations (`PrimitiveValueState`, `BytesValueState`, `GenericValueState`) 
and keeps one `is_set` bit per group.
   - `FirstValue::groups_accumulator_supported` no longer requires an ordering. 
`LastValue` and ordered `FirstValue` are unchanged.
   
   A group takes the first row that passes the `FILTER` clause (and, with 
`IGNORE NULLS`, is not null), and is never written again. That is the row 
`TrivialFirstValueAccumulator` keeps: rows within a batch in input order, and 
batches in arrival order. `merge_batch` applies the same rule to rows whose 
`is_set` flag is true.
   
   The intermediate state is `[first_value, is_set]`, the same layout as 
`TrivialFirstValueAccumulator`, so partial and final aggregation see the same 
schema as before. `convert_to_state` sets `is_set` from the filter and `IGNORE 
NULLS`, so skipped partial aggregation keeps the same semantics.
   
   A batch costs one bit test per row plus one value copy per newly decided 
group. Nothing visits groups the batch has no rows for.
   
   ## What is the testing strategy for this PR?
   
   Yes. New unit tests in `first_last.rs` cover:
   
   - `RESPECT NULLS` keeping a leading null, and later batches not overwriting 
a group's value
   - `IGNORE NULLS`
   - a `FILTER` predicate that is null (excluded)
   - `merge_batch` keeping existing values, skipping rows with `is_set = 
false`, and filling groups out of order
   - `convert_to_state` folding `FILTER` and `IGNORE NULLS` into `is_set`
   - `EmitTo::First`
   - Utf8 and List values
   
   ### Benchmarks
   
   ClickBench `hits.parquet` (`./benchmarks/bench.sh data clickbench_1`), 
`datafusion-cli` release builds of `main` (b6b2e0e) and this PR, Apple M4 Pro 
(14 cores, 24 GB). Mean of 5 runs after 1 warmup (`hyperfine`); peak memory is 
the process peak footprint from `/usr/bin/time -l`, median of 3 runs.
   
   | Query | Groups | `main` | This PR | Speedup | Peak memory `main` → PR |
   |---|---|---|---|---|---|
   | Q1 `DISTINCT ON ("UserID")`, 5 columns | 17.6M | 73.3 s ± 12.7 | 6.1 s ± 
0.9 | 12.0x | 21.2 GB → 11.3 GB |
   | Q2 `first_value("URL")` by `UserID` | 17.6M | 5.78 s | 0.95 s | 6.1x | 8.3 
GB → 5.7 GB |
   | Q3 `first_value("WatchID")` by `UserID` | 17.6M | 6.24 s | 0.34 s | 18.3x 
| 6.6 GB → 2.4 GB |
   | Q4 `DISTINCT ON ("OS")`, 5 columns | 91 | 1.21 s | 1.13 s | 1.07x | 1.6 GB 
→ 1.6 GB |
   
   On `main`, Q1 peaks close to the machine's 24 GB, so part of its time and 
variance is likely memory pressure. Q4 has few groups and shows no regression.
   
   **Queries**:
   
   ```sql
   CREATE EXTERNAL TABLE hits STORED AS PARQUET LOCATION 
'benchmarks/data/hits.parquet';
   
   -- Q1
   SELECT MAX(LENGTH("URL")), MAX(LENGTH("Title")), 
MAX(LENGTH("SearchPhrase")), MAX("EventTime"), MAX("RegionID")
   FROM (
       SELECT DISTINCT ON ("UserID") "UserID", "URL", "Title", "SearchPhrase", 
"EventTime", "RegionID"
       FROM hits
   );
   
   -- Q2
   SELECT MAX(LENGTH(u)) FROM (
       SELECT "UserID", first_value("URL") AS u FROM hits GROUP BY "UserID"
   );
   
   -- Q3
   SELECT MAX(w) FROM (
       SELECT "UserID", first_value("WatchID") AS w FROM hits GROUP BY "UserID"
   );
   
   -- Q4
   SELECT MAX(LENGTH("URL")), MAX(LENGTH("Title")), 
MAX(LENGTH("SearchPhrase")), MAX("EventTime"), MAX("RegionID")
   FROM (
       SELECT DISTINCT ON ("OS") "OS", "URL", "Title", "SearchPhrase", 
"EventTime", "RegionID"
       FROM hits
   );
   ```
   
   The outer `MAX`s keep every `first_value` in the plan while printing one 
row. Results of unordered `first_value` depend on partition scheduling on both 
`main` and this PR; with `target_partitions = 1`, both return identical results.
   
   ## Are there any user-facing changes?
   
   No API changes. Queries using `first_value` without `ORDER BY`, including 
`DISTINCT ON` without `ORDER BY`, use less time and memory. Which row is 
"first" is unchanged: as before, it depends on input order.
   


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