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

   ## Which issue does this PR close?
   
   - Closes #27046.
   
   ## Rationale for this change
   
   A grouped `first_value(x ORDER BY ts)` / `last_value(x ORDER BY ts)` with 
`ts` a
   `Timestamp(_, Some(tz))` gets slower as `target_partitions` grows, and so 
does every
   `DISTINCT ON` with a time zone aware key (it is planned as `first_value` per 
column). At 10
   partitions it is 3.5-14x slower than the same query ordered by a naive 
timestamp. We found it with
   a benchmark (digibench) where a `DISTINCT ON (session_id, event_name, 
client_ts)` dedup was 25x
   slower than DuckDB.
   
   The cause is in `FirstLastGroupsAccumulator`: it keeps each group's winning 
ordering values as
   `Vec<Vec<ScalarValue>>`. A time zone aware `ScalarValue::Timestamp*` holds 
its time zone as an
   `Arc<str>`, and that `Arc` comes from the column's `DataType`, so it is 
shared by all partitions.
   Extracting a winner row, replacing a group's orderings (`clear()` + 
`extend_from_slice()`), and
   growing the groups (`resize(n, default_orderings.clone())`) each clone or 
drop it, i.e. several
   atomic operations on one cache line per updated group, from every thread at 
once.
   
   The time zone does not take part in the comparison (the values are UTC 
instants), so the
   accumulator does not need it.
   
   ## What changes are included in this PR?
   
   In `FirstLastGroupsAccumulator` 
(`datafusion/functions-aggregate/src/first_last.rs`):
   
   - `try_new` records the time zone of each ordering column 
(`ordering_timezones`) and builds
     `default_orderings` without it.
   - `update_batch` and `merge_batch` relabel the time zone aware ordering 
columns of their input as
     time-zone-free before anything else 
(`PrimitiveArray::with_timezone_opt(None)`: no copy, no value
     change, unlike a cast). From there on the stored `ScalarValue`s carry 
`None` and cloning them is a
     plain copy. When no ordering column has a time zone the input is passed 
through unchanged
     (`Cow::Borrowed`).
   - `state` puts the original time zone back on the ordering columns it emits, 
so the intermediate
     state schema is unchanged.
   
   Results, the state schema, plans and the non-timestamp paths are unchanged. 
Size accounting now
   reports slightly less per stored ordering value, because a time-zone-free 
`ScalarValue::size()`
   no longer counts the (shared) time zone string.
   
   Not changed: the non-grouped `FirstValueAccumulator` / 
`LastValueAccumulator` extract their
   ordering row once per accumulator and batch rather than once per updated 
group, so they are much
   less exposed; `nth_value` keeps `ScalarValue` ordering rows too and may have 
a milder form of the
   same contention; ordered `array_agg` already stores encoded rows. The 
general fix, keeping
   per-group orderings in the `arrow::row` format or as typed columns instead 
of `ScalarValue`s
   (which would also remove the remaining per-group `ScalarValue` work for 
every ordering type, and
   cover time zone aware timestamps nested in structs), is a larger refactor 
and is left as a
   follow-up.
   
   ## What is the testing strategy for this PR?
   
   - Unit test `test_group_acc_timezone_ordering` (first and last): the stored 
orderings carry no time
     zone, `size_of_orderings` stays consistent, the `state()` ordering column 
has the original
     `Timestamp(µs, "+02:00")` type also when the state is emitted in parts 
(`EmitTo::First`, then
     `EmitTo::All`), and merging those states with another partial state gives 
the right values.
   - `aggregate.slt`: `first_value` / `last_value` ordered by a `Timestamp(µs, 
"+05:00")` column over a
     `UNION ALL` of two tables (multi-partition, partial state merged in the 
final stage), with NULL
     ordering values (`NULLS FIRST` / `NULLS LAST`), ties broken by a second 
key, the time zone aware
     column as the second ordering key, `DESC`, the time zone aware value 
itself as the result
     (`arrow_typeof` keeps the time zone, displayed values are not shifted), 
`DISTINCT ON` with a
     time zone aware key, and second, millisecond and nanosecond timestamps 
with three different time
     zones as ordering keys, with and without `IGNORE NULLS`.
   - Existing `first_last` unit tests and the `aggregate`, `first_last_*`, 
`distinct_on` and
     `ordered_aggregate_*` sqllogictests pass.
   
   Benchmark (`datafusion-cli --release`, 2M rows in 1M groups of 2 rows whose 
order in `ts` is
   scrambled, data generated in SQL; queries in the issue). Median of 5 hot 
runs after a warm-up, ms,
   two interleaved rounds (main, branch, main, branch) shown as `round 1 / 
round 2`, Apple M2 Pro,
   10 cores, otherwise idle:
   
   | query (ordered by `Timestamp(µs, "UTC")`) | target_partitions | main | 
this PR |
   |---|---|---|---|
   | `first_value(v ORDER BY ts)` GROUP BY k1, k2 | 1  | 193 / 194 | 189 / 194 |
   |                                              | 4  | 324 / 329 | 104 / 104 |
   |                                              | 10 | 491 / 505 | 97 / 99 |
   | `last_value(v ORDER BY ts)` GROUP BY k1, k2  | 1  | 191 / 191 | 192 / 191 |
   |                                              | 4  | 337 / 318 | 106 / 104 |
   |                                              | 10 | 485 / 502 | 98 / 96 |
   | `DISTINCT ON (k1) ... ORDER BY k1, ts, o`    | 1  | 266 / 267 | 267 / 266 |
   |                                              | 4  | 443 / 440 | 165 / 165 |
   |                                              | 10 | 558 / 573 | 157 / 153 |
   
   With this PR the time zone aware queries run at the speed of the same 
queries ordered by a naive
   timestamp (10 partitions: 97-99 ms for `first_value` / `last_value`, 153-161 
ms for `DISTINCT ON`,
   on both builds), i.e. about 5x faster for `first_value` / `last_value` and 
3.6x for `DISTINCT ON` at
   10 partitions and unchanged at 1. A latest-state-per-order query (10M 
events, 2M orders,
   `DISTINCT ON (order_id) ... ORDER BY order_id, ingest_ts DESC`, default 
partitions) goes from
   6.77-6.92 s to 0.47-0.52 s (about 14x), the same as with a naive `ingest_ts` 
(0.50-0.54 s). Query
   results are identical between main and the branch (and, for the latest-state 
query, with DuckDB).
   
   ## Are there any user-facing changes?
   
   No, only performance.
   


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