Sunninsky opened a new issue, #27400:
URL: https://github.com/apache/datafusion/issues/27400

   ### Is your feature request related to a problem or challenge?
   
   Window aggregates and ranking functions cost far more per row than the work 
they do, and with small partitions a fixed cost per
   partition dominates. On `main` (`55de86155`), window aggregates 
(`SUM/COUNT/AVG/MIN/MAX OVER (... ROWS/RANGE ...)`) cost **160 to 779 ns per 
row**, and ranking functions and `lag` 28 to 87 ns per row, against a floor of 
3.8 ns per row (scan + projection + final aggregate; 3,000,000 rows in 
60,000-row partitions, x86-64 node).
   
   Related: #23197 (epic), #26541 (per-partition cost in 
`BoundedWindowAggExec`, assigned), #23273, #22355 / #22356, #15607.
   
   #### Reproducer
   
   datafusion-python 55.0.0 (DataFusion 55.2.0). The operator's own 
`elapsed_compute` from `EXPLAIN ANALYZE`, one partition of the
   plan, so the scan and the sort are not counted:
   
   ```python
   from datafusion import SessionConfig, SessionContext
   
   ctx = SessionContext(SessionConfig().with_target_partitions(1))
   ctx.sql("CREATE TABLE t AS SELECT value / 60000 AS k, value AS ts, (value * 
7919) % 2000 - 1000 AS v "
           "FROM generate_series(0, 2999999)").collect()          # 3M rows, 
60,000-row partitions; use `value / 10` for 10-row ones
   w = "SUM(v) OVER (PARTITION BY k ORDER BY ts ROWS BETWEEN 5 PRECEDING AND 5 
FOLLOWING)"
   ctx.sql(f"EXPLAIN ANALYZE SELECT sum(w) FROM (SELECT {w} AS w FROM 
t)").show()   # BoundedWindowAggExec ... elapsed_compute=
   ```
   
   The script that runs seven shapes and prints ns per input row is in the 
details block below. Output on a dedicated x86-64 node
   (`r7i.8xlarge`; the process pinned to two cores, nothing else running):
   
   ```
   $ python window_repro.py 3000000 60000
   3,000,000 rows, 60,000 rows per partition, target_partitions = 1
   row_number()                                         BoundedWindowAggExec    
   25.8 ns/row
   lag(v, 1)                                            BoundedWindowAggExec    
   37.3 ns/row
   SUM(v), default frame                                BoundedWindowAggExec    
  283.8 ns/row
   SUM(v), ROWS 5 PRECEDING .. 5 FOLLOWING              BoundedWindowAggExec    
  229.8 ns/row
   MAX(v), ROWS 5 PRECEDING .. 5 FOLLOWING              BoundedWindowAggExec    
  206.6 ns/row
   SUM(v), RANGE 5 PRECEDING .. 5 FOLLOWING             BoundedWindowAggExec    
  756.7 ns/row
   SUM(v) OVER (PARTITION BY k)  [whole partition]      WindowAggExec           
    6.1 ns/row
   
   $ python window_repro.py 3000000 10
   3,000,000 rows, 10 rows per partition, target_partitions = 1
   row_number()                                         BoundedWindowAggExec    
   87.8 ns/row
   lag(v, 1)                                            BoundedWindowAggExec    
  135.7 ns/row
   SUM(v), default frame                                BoundedWindowAggExec    
  403.3 ns/row
   SUM(v), ROWS 5 PRECEDING .. 5 FOLLOWING              BoundedWindowAggExec    
  263.5 ns/row
   MAX(v), ROWS 5 PRECEDING .. 5 FOLLOWING              BoundedWindowAggExec    
  253.2 ns/row
   SUM(v), RANGE 5 PRECEDING .. 5 FOLLOWING             BoundedWindowAggExec    
  783.3 ns/row
   SUM(v) OVER (PARTITION BY k)  [whole partition]      WindowAggExec           
   55.6 ns/row
   ```
   
   <details><summary>reproducer script</summary>
   
   ```python
   """Per-row cost of window evaluation in DataFusion, from the operator's own 
`elapsed_compute` metric.
   
       pip install datafusion          # tested with datafusion-python 55.0.0 
(DataFusion 55.2.0)
       python window_repro.py [ROWS] [ROWS_PER_PARTITION]
   
   The table is 3,000,000 rows in 60,000-row `PARTITION BY k` groups by 
default, one thread, so the numbers are the cost of
   the window operator alone (`EXPLAIN ANALYZE`, operator 
`BoundedWindowAggExec`; the sort below it is not counted).
   """
   import re
   import sys
   
   from datafusion import SessionConfig, SessionContext
   
   ROWS = int(sys.argv[1]) if len(sys.argv) > 1 else 3_000_000
   PART = int(sys.argv[2]) if len(sys.argv) > 2 else 60_000
   OVER = "PARTITION BY k ORDER BY ts"
   SHAPES = [
       ("row_number()", f"row_number() OVER ({OVER})"),
       ("lag(v, 1)", f"lag(v, 1) OVER ({OVER})"),
       ("SUM(v), default frame", f"SUM(v) OVER ({OVER})"),
       ("SUM(v), ROWS 5 PRECEDING .. 5 FOLLOWING", f"SUM(v) OVER ({OVER} ROWS 
BETWEEN 5 PRECEDING AND 5 FOLLOWING)"),
       ("MAX(v), ROWS 5 PRECEDING .. 5 FOLLOWING", f"MAX(v) OVER ({OVER} ROWS 
BETWEEN 5 PRECEDING AND 5 FOLLOWING)"),
       ("SUM(v), RANGE 5 PRECEDING .. 5 FOLLOWING", f"SUM(v) OVER ({OVER} RANGE 
BETWEEN 5 PRECEDING AND 5 FOLLOWING)"),
       ("SUM(v) OVER (PARTITION BY k)  [whole partition]", "SUM(v) OVER 
(PARTITION BY k)"),
   ]
   UNIT = {"ns": 1.0, "µs": 1e3, "us": 1e3, "ms": 1e6, "s": 1e9}
   
   ctx = SessionContext(SessionConfig().with_target_partitions(1))
   ctx.sql(
       f"CREATE TABLE t AS SELECT value / {PART} AS k, value AS ts, (value * 
7919) % 2000 - 1000 AS v "
       f"FROM generate_series(0, {ROWS - 1})"
   ).collect()
   
   
   def window_nanos(window):
       """elapsed_compute of the window operator, in ns, for SELECT sum(w) FROM 
(SELECT <window> AS w FROM t)."""
       batches = ctx.sql(f"EXPLAIN ANALYZE SELECT sum(w) FROM (SELECT {window} 
AS w FROM t)").collect()
       for b in batches:
           for kind, plan in zip(b.column(0).to_pylist(), 
b.column(1).to_pylist()):
               if kind != "Plan with Metrics":
                   continue
               for line in plan.splitlines():
                   if "WindowAggExec" in line:
                       m = 
re.search(r"elapsed_compute=([0-9.]+)(ns|µs|us|ms|s)\b", line)
                       return float(m.group(1)) * UNIT[m.group(2)], 
line.split(":")[0].strip()
       raise RuntimeError("no window operator in the plan")
   
   
   print(f"{ROWS:,} rows, {PART:,} rows per partition, target_partitions = 1")
   for label, window in SHAPES:
       best = min((window_nanos(window) for _ in range(3)), key=lambda r: r[0])
       print(f"{label:52} {best[1]:22} {best[0] / ROWS:8.1f} ns/row")
   ```
   
   </details>
   
   #### Where the time goes on `main`
   
   * Per row: the frame, two array slices and a `Vec<ArrayRef>`, up to three 
dynamic calls (`update_batch`, `retract_batch`,
     `evaluate`), a `ScalarValue` result and a final 
`ScalarValue::iter_to_array` (`AggregateWindowExpr::get_result_column`).
     `RANGE` with an offset also searches the peer group linearly, copying one 
row into a `Vec<ScalarValue>` per step
     (`WindowFrameStateRange::calculate_index_of_row`). The frame size does not 
matter: `ROWS 5 PRECEDING .. 5 FOLLOWING` and `ROWS 500 PRECEDING .. 500 
FOLLOWING` cost 261 and 259 ns per row on x86-64, 406 and 404 ns per row on 
arm64.
   * Per partition (`SortedSearch`): a `Vec<ScalarValue>` key hashed several 
times per batch and per expression, a `WindowState` per
     partition and expression, per-poll hash maps in `calculate_n_out_row`, 
pruning. This is the cost #26541 describes.
   
   ### Describe the solution you'd like
   
   A throwaway spike (no PR yet) with two steps. Both add provided methods 
whose default keeps today's behaviour; nothing changes for
   a user-defined function that does not opt in.
   
   1. **Bulk frames.** The frames of a whole run of rows in one pass 
(`WindowFrameContext::calculate_ranges`: `ROWS` closed form,
      `RANGE` peers from an adjacent-inequality vector, `RANGE` offsets for 
integer and timestamp keys by a typed two-pointer), an
      accumulator hook that evaluates a run of frames in one call 
(`Accumulator::evaluate_window_frames`; typed kernels for
      `SUM/COUNT/AVG/MIN/MAX` that add and retract in the same order as today, 
so floats stay bit-identical) and
      `PartitionEvaluator::evaluate_rows` for causal frame-free functions 
(`row_number`, `rank`, `dense_rank`, `lag`).
   2. **Complete partitions evaluated directly.** In `Sorted` mode a run of 
partitions that start and end inside one input batch is
      evaluated with one call per window expression on the batch, without 
per-partition key, state, hash map or pruning
      (`WindowExpr::evaluate_complete_partitions`). Its provided implementation 
runs the existing `evaluate_stateful` on each
      partition, so any expression, user-defined ones included, behaves as 
before; aggregates and the functions above have segmented
      fast paths (`WindowFrameContext::calculate_ranges_segmented`, 
`Accumulator::evaluate_window_segments`,
      `PartitionEvaluator::evaluate_segments`). It is used when at least 4 
complete partitions are in the batch and at most one open
      partition is buffered, never with a `WindowStateObserver`. This is the 
second direction of #26541.
   
   New public items: `Accumulator::evaluate_window_frames`, 
`Accumulator::evaluate_window_segments`,
   `PartitionEvaluator::evaluate_rows`, 
`PartitionEvaluator::evaluate_segments`, 
`WindowExpr::evaluate_complete_partitions` (all provided
   methods), `WindowFrameContext::calculate_ranges`, 
`WindowFrameContext::calculate_ranges_segmented`, `CompletePartitions`,
   `evaluate_complete_partitions_stateful`, `adjacent_distinct`, and a 
`window_kernels` module in `datafusion-functions-aggregate-common`.
   Names are provisional.
   
   #### Results
   
   CPU time of the whole query thread (scan, window operator, final aggregate). 
The runs of `main` and of the prototype alternate on
   one pinned core of a dedicated node; in-memory tables already in partition 
order, 1M to 3M rows, one thread; the speedup is the median
   over 3 rounds of the ratio taken inside a round:
   
   **x86-64 (Intel Xeon Platinum 8488C, Sapphire Rapids)**: ns per input row, 
upstream main -> prototype (speedup, median of paired rounds)
   
   | shape | partitions of 60,000 rows | partitions of 240 rows | partitions of 
10 rows | partitions of 1 row |
   |---|---|---|---|---|
   | row_number | 28 -> 5.7 (5x) | 31 -> 5.9 (5x) | 93 -> 6.1 (15x) | 675 -> 10 
(65x) |
   | rank (no ties) | 83 -> 8.2 (10x) | 87 -> 7.9 (11x) | 191 -> 7.9 (24x) | 
1139 -> 12 (99x) |
   | lag(v,1) | 39 -> 6.8 (6x) | 45 -> 7.9 (6x) | 141 -> 9.7 (15x) | 1116 -> 12 
(93x) |
   | first_value (default frame) | 134 -> 11 (12x) | 140 -> 13 (11x) | 243 -> 
13 (19x) | 1229 -> 17 (74x) |
   | SUM default frame (RANGE running) | 288 -> 15 (19x) | 292 -> 14 (21x) | 
414 -> 14 (28x) | 1536 -> 19 (82x) |
   | SUM ROWS 5..5 | 261 -> 13 (20x) | 262 -> 13 (20x) | 290 -> 15 (19x) | 1422 
-> 20 (70x) |
   | MAX ROWS 5..5 | 247 -> 14 (17x) | 252 -> 15 (17x) | 285 -> 14 (20x) | 1440 
-> 21 (68x) |
   | SUM RANGE 5..5 (offset) | 779 -> 22 (35x) | 795 -> 21 (37x) | 797 -> 23 
(35x) | 1946 -> 42 (46x) |
   | 4 aggs ROWS 5..5 (shared window) | 937 -> 33 (29x) | 947 -> 35 (27x) | 961 
-> 38 (26x) | 4467 -> 52 (84x) |
   | SUM whole partition | 13 -> 13 (1.0x) | 15 -> 15 (1.0x) | 63 -> 63 (1.0x) 
| 554 -> 566 (1.0x) |
   
   The last row is a control: `SUM(v) OVER (PARTITION BY k)` runs in 
`WindowAggExec`, which the prototype does not touch. The query is a
   scan, one window operator and a final aggregate (no sort, no I/O); a real 
query pays for the rest as well, so its gain depends on how
   much of its time the window operator is. Noise: the same binary run twice 
(A/A, 10-row partitions, 5 rounds) differs by at most 1.05x on x86-64 and 1.12x 
on arm64 in a single round; the control row, whose code the prototype does not 
change, moves by 0.91x to 1.03x between the three different binaries (median 
over the rounds, every partition size), so differences of about that size 
between binaries are not evidence of a change in the code.
   
   <details><summary>arm64 (Graviton3) and the other partition sizes</summary>
   
   **arm64 (AWS Graviton3, Neoverse-V1)**: ns per input row, upstream main -> 
prototype (speedup, median of paired rounds)
   
   | shape | partitions of 60,000 rows | partitions of 240 rows | partitions of 
10 rows | partitions of 1 row |
   |---|---|---|---|---|
   | row_number | 29 -> 8.6 (3x) | 33 -> 9.5 (4x) | 134 -> 9.3 (15x) | 951 -> 
14 (66x) |
   | rank (no ties) | 94 -> 13 (7x) | 100 -> 12 (8x) | 266 -> 12 (22x) | 1657 
-> 15 (107x) |
   | lag(v,1) | 49 -> 9.2 (5x) | 60 -> 12 (5x) | 209 -> 15 (14x) | 1618 -> 18 
(91x) |
   | first_value (default frame) | 213 -> 16 (13x) | 219 -> 19 (11x) | 380 -> 
20 (19x) | 1739 -> 24 (74x) |
   | SUM default frame (RANGE running) | 453 -> 20 (22x) | 460 -> 20 (23x) | 
651 -> 21 (31x) | 2296 -> 26 (88x) |
   | SUM ROWS 5..5 | 406 -> 18 (22x) | 435 -> 19 (23x) | 450 -> 24 (19x) | 2119 
-> 29 (74x) |
   | MAX ROWS 5..5 | 356 -> 20 (18x) | 388 -> 22 (18x) | 424 -> 21 (20x) | 2186 
-> 29 (76x) |
   | SUM RANGE 5..5 (offset) | 1267 -> 26 (49x) | 1286 -> 26 (49x) | 1245 -> 31 
(40x) | 2903 -> 50 (58x) |
   | 4 aggs ROWS 5..5 (shared window) | 1384 -> 45 (30x) | 1486 -> 48 (31x) | 
1478 -> 52 (28x) | 6812 -> 66 (103x) |
   | SUM whole partition | 7.9 -> 7.9 (1.0x) | 13 -> 13 (1.0x) | 90 -> 89 
(1.0x) | 799 -> 790 (1.0x) |
   
   Speedup of the prototype over `main` on dedicated nodes (one pinned core, 
median over paired rounds of the ratio taken inside a round), range over the 11 
shapes `row_number`, `rank`, `lag`, `first_value`, `SUM`/`COUNT`/`AVG` default 
frame, `SUM`/`MAX` `ROWS 5..5`, `SUM RANGE 5..5`, four aggregates sharing a 
window, by rows per partition: x86-64: 60,000 rows: 5-35x; 240 rows: 5-37x; 10 
rows: 15-35x; 4 rows: 20-43x; 2 rows: 24-60x; 1 row: 46-99x | arm64: 60,000 
rows: 3-49x; 240 rows: 4-49x; 10 rows: 14-40x; 4 rows: 20-42x; 2 rows: 26-65x; 
1 row: 58-107x.
   
   </details>
   
   #### Correctness evidence
   
   * A differential fuzzer runs random window queries (`ROWS` / `RANGE` / 
`GROUPS`, offsets, nullable and extreme keys, floats with
     `NaN` / `inf` / `-0.0`, timestamps, string and nullable PARTITION BY keys, 
batch sizes 1 to 8192, `Sorted` and `Linear` modes) twice in
     one process, once with the original per-row code and once with the 
prototype, and compares the results (floats by bits, every `NaN` as
     equal): 381,600 queries on the final tree in 10 configurations (the parts 
of the prototype switched on and off, long partitions, standard functions only, 
aggregates only), 0 mismatches and 0 queries that failed on one side only; 
80,243 of the queries are rejected identically by both sides (for example 
invalid frames). Earlier iterations ran 522,000 more queries; their only 
mismatches (377, all in the first campaign) were `NaN` against `NaN` with a 
different sign.
   * The existing suites pass on the final tree: unit tests of the seven 
touched crates (5,564 passed), sqllogictest `window` (7 files), the extended 
`window` fuzz cases (4 passed), `core_integration window` (126 passed).
   
   ### Describe alternatives you've considered
   
   * Cheaper per-partition keys (row format instead of `Vec<ScalarValue>`), as 
#26541 notes, cut the hashing but keep the per-partition
     batches and states, so the cost stays per partition.
   * A new kernel API for window evaluation (#23273, #15607) is the longer-term 
direction. The spike adds only provided methods that fall
     back to today's protocol, so it does not preclude one.
   * Intra-operator parallelism (#22355) targets one huge partition; the shapes 
above (many partitions) are a different case.
   
   ### Additional context
   
   #### Unknowns, limits
   
   * Not covered: `GROUPS` frames, `FILTER`, aggregates without a kernel, `lag 
... IGNORE NULLS`, float / decimal / date `RANGE` offsets,
     user-defined functions (they keep today's cost until they implement a 
hook). `WindowAggExec` and the `Linear` / `PartiallySorted`
     modes keep their per-partition cost (the control row above).
   * With 40 or 150 extra nullable columns carried through the window (500,000 
rows, partitions of 10 rows) the only shape slower than step 1 alone is `GROUPS 
2..2` in three expressions (no fast path): 0.90x (x86-64, 150 columns), 0.97x 
(arm64, 150 columns), 0.97x (x86-64, 40 columns); every other shape and table 
width is as fast or faster (median of 3 paired rounds).
   * The sign of a `NaN` that arithmetic generates (`inf - inf`) is hardware 
dependent and can differ between the kernel and the per-row
     protocol; whether that is observable through SQL was not investigated. 
Float sums keep the add / retract order of `main`, so a frame
     that has held `Infinity` or `NaN` gives what it gives today (#26222): the 
kernels reproduce it and a fix there would have to cover them.
   * Not measured: `target_partitions > 1`, spilling and memory accounting 
(#26707), I/O-bound queries.
   * The timings were taken on a build that differs from the validated tree 
only by lint edits (two `try_reserve` calls and `#[expect]` attributes); timed 
against each other on x86-64 (9 rounds: 0.96-1.03x over 48 shape/partition-size 
combinations, 0 with a different checksum) the two builds are within the noise 
floor. The arm64 timings were not repeated.
   * The spike toggles its behaviour with process-global flags read from the 
environment; a PR would need instance-level configuration
     or no opt-out.
   
   #### Questions for maintainers
   
   1. #26541 is assigned and its second bullet is step 2 above. Should that 
part stay with its assignee (this design as a reference), or
      would a PR built on it be welcome? This is raised as an issue first; no 
PR has been opened.
   2. Is a provided method on `Accumulator` / `PartitionEvaluator` the right 
shape, or should the kernels live behind the kernel API
      direction of #23273?
   3. A possible order: benchmarks for these shapes 
(`core/benches/window_query_sql.rs` has no moving-frame, `RANGE`-offset, 
`rank`, `lag`
      or small-partition shape), then bulk frame bounds, then the accumulator 
kernels one aggregate at a time, then step 2.
   
   The prototype can be shared on request.
   
   **AI disclosure.** The prototype, the measurements and this text were 
produced with Claude Code (an AI coding assistant) under the
   supervision of the account owner, who directed the work and approved posting 
this issue. Every number comes from a log of the runs
   described above.
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


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