[ 
https://issues.apache.org/jira/browse/SPARK-59658?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

James Xu updated SPARK-59658:
-----------------------------
    Description: 
Spark executes a bounded moving window frame by re-aggregating every row 
currently inside the frame on every output row: {{SlidingWindowFunctionFrame}} 
resets the aggregate buffer and re-folds the whole frame for each row produced. 
For a window operator with nested sliding frames over one (PARTITION BY, ORDER 
BY) key, the cost is the sum of the frame widths (Σw) aggregate-update 
evaluations per output row, and the cost is structural — every added horizon 
multiplies the work. There is no incremental path: the window aggregate 
processor only supports {{{}initialize{}}}/{{{}update{}}}/{{{}evaluate{}}}, 
with no {{{}remove{}}}, so a moving frame cannot be advanced row-by-row.

The pattern is common in user-facing metric tables that compute several rolling 
horizons per measure over a daily partition ordering. A realistic example:
{code:sql}
SELECT
    seller_user_id,
    sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
                ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)   AS cnt_3d,
    sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
                ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)   AS cnt_7d,
    sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
                ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)  AS cnt_30d,
    sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
                ROWS BETWEEN 89 PRECEDING AND CURRENT ROW)  AS cnt_90d,
    sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
                ROWS BETWEEN 179 PRECEDING AND CURRENT ROW) AS cnt_180d
    -- ... repeated for several more measures
FROM daily_aggregates{code}
This shape measures 5 horizons, i.e. Σw = 3+7+30+90+180 = 310 aggregate-update 
evaluations per output row per measure family.

In a production workload measured against this shape (a 3,437 CPU-hour 
application), 45% of the CPU sits inside the window operator, and the window 
stage's own input-sort spill reaches 14.8 TiB on the largest query.
h3. Root cause

{{SlidingWindowFunctionFrame.write}} re-runs the aggregate update for every row 
in the frame on every output row. Growing frames are incremental and offset 
frames are O(1), so the sliding frame is the only path with no O(1) evaluation. 
The work factor is exactly Σw (sum of frame widths) per group per row.
h3. Proposed change

Add an optimizer rule that rewrites each group of qualifying sliding frames as 
one running aggregate plus offset differences:
{code:java}
-- Running total, reused as the base for every sliding window
C = sum(x) OVER (PARTITION BY k ORDER BY d
                ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

-- n-row sliding sum == running total minus the running total n+1 rows back
sum(x) OVER (PARTITION BY k ORDER BY d
            ROWS BETWEEN n PRECEDING AND CURRENT ROW)
    = C - coalesce(lag(C, n + 1) OVER (PARTITION BY k ORDER BY d), 0){code}
For integral accumulators with ANSI off this is provably bit-identical: the 
original fold already computes with wrapping addition, and modular reduction 
commutes with subtraction, so the running prefix — a magnitude no bounded frame 
can reach — overflows harmlessly. Floating-point accumulators (non-associative 
addition) and decimal accumulators (overflow becomes a silent NULL) are 
excluded, and a nullable measure carries a running count so an all-NULL frame 
still returns NULL. The rewrite reuses the window operator's own partition and 
order spec, so no new exchange or sort is introduced.

The rule is conservative: only non-distinct, unfiltered {{sum}} over integral 
measures with literal {{n PRECEDING AND CURRENT ROW}} frames qualifies, groups 
below a minimum total frame width are skipped, and it is gated behind an opt-in 
configuration flag, default off.

  was:
Spark executes a bounded moving window frame by re\-aggregating every row 
currently inside the frame on every output row: {{SlidingWindowFunctionFrame}} 
resets the aggregate buffer and re\-folds the whole frame for each row 
produced. For a window operator with nested sliding frames over one \(PARTITION 
BY, ORDER BY\) key, the cost is the sum of the frame widths \(Σw\) 
aggregate\-update evaluations per output row, and the cost is structural — 
every added horizon multiplies the work. There is no incremental path: the 
window aggregate processor only supports 
{{initialize}}/{{update}}/{{evaluate}}, with no {{remove}}, so a moving frame 
cannot be advanced row\-by\-row.

The pattern is common in user\-facing metric tables that compute several 
rolling horizons per measure over a daily partition ordering. A realistic 
example:

{code:sql}
SELECT
  seller\_user\_id,
  sum\(cnt\)    OVER \(PARTITION BY seller\_user\_id ORDER BY receive\_date
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW\) AS cnt\_3d,
  sum\(cnt\)    OVER \(PARTITION BY seller\_user\_id ORDER BY receive\_date
      ROWS BETWEEN 6 PRECEDING AND CURRENT ROW\) AS cnt\_7d,
  sum\(cnt\)    OVER \(PARTITION BY seller\_user\_id ORDER BY receive\_date
      ROWS BETWEEN 29 PRECEDING AND CURRENT ROW\) AS cnt\_30d,
  sum\(cnt\)    OVER \(PARTITION BY seller\_user\_id ORDER BY receive\_date
      ROWS BETWEEN 89 PRECEDING AND CURRENT ROW\) AS cnt\_90d,
  sum\(cnt\)    OVER \(PARTITION BY seller\_user\_id ORDER BY receive\_date
      ROWS BETWEEN 179 PRECEDING AND CURRENT ROW\) AS cnt\_180d
  \-\- ...repeated for several more measures
FROM daily\_aggregates
{code}

This shape measures 5 horizons, i.e. Σw = 3\+7\+30\+90\+180 = 310 
aggregate\-update evaluations per output row per measure family.

In a production workload measured against this shape \(a 3,437 CPU\-hour 
application\), 45% of the CPU sits inside the window operator, and the window 
stage's own input\-sort spill reaches 14.8 TiB on the largest query.

h3. Root cause

{{SlidingWindowFunctionFrame.write}} re\-runs the aggregate update for every 
row in the frame on every output row. Growing frames are incremental and offset 
frames are O\(1\), so the sliding frame is the only path with no O\(1\) 
evaluation. The work factor is exactly Σw \(sum of frame widths\) per group per 
row.

h3. Proposed change

Add an optimizer rule that rewrites each group of qualifying sliding frames as 
one running aggregate plus offset differences:

{code}
C = sum\(x\) OVER \(PARTITION BY k ORDER BY d ROWS BETWEEN
      UNBOUNDED PRECEDING AND CURRENT ROW\)
sum\(x\) OVER \(... ROWS BETWEEN n PRECEDING AND CURRENT ROW\)
  = C \- coalesce\(lag\(C, n \+ 1\) OVER \(PARTITION BY k ORDER BY d\), 0\)
{code}

For integral accumulators with ANSI off this is provably bit\-identical: the 
original fold already computes with wrapping addition, and modular reduction 
commutes with subtraction, so the running prefix — a magnitude no bounded frame 
can reach — overflows harmlessly. Floating\-point accumulators 
\(non\-associative addition\) and decimal accumulators \(overflow becomes a 
silent NULL\) are excluded, and a nullable measure carries a running count so 
an all\-NULL frame still returns NULL. The rewrite reuses the window operator's 
own partition and order spec, so no new exchange or sort is introduced.

The rule is conservative: only non\-distinct, unfiltered {{sum}} over integral 
measures with literal {{n PRECEDING AND CURRENT ROW}} frames qualifies, groups 
below a minimum total frame width are skipped, and it is gated behind an 
opt\-in configuration flag, default off.

h3. Expected impact* Per\-group per\-row work drops from Σw to H \+ 2 aggregate 
evaluations \(H = number of distinct widths\): 310 → 7 \(44x\) for the example 
shape.
* Microbenchmark on the production shape \(6 measures, widths 2/6/29/89/179, 2M 
rows\): 19.7s → 1.69s per window pass \(11.6x\); results verified 
bit\-identical against the original plan, including under prefix overflow past 
2\^63.
* On the real production query shape, a measured A/B of the rewritten form 
shows \~10.5x end\-to\-end and \~20x on the window operator alone.
* Production model for one risk\-model workload family \(21 nodes, window stage 
4,424 CPU\-hours\): \~3,481 CPU\-hours recoverable \(projected\).
* No new exchange or sort: both window operators share one sort, and 
Exchange/SortExec counts are unchanged after the rewrite.




> Rewrite sliding window frames as prefix differences
> ---------------------------------------------------
>
>                 Key: SPARK-59658
>                 URL: https://issues.apache.org/jira/browse/SPARK-59658
>             Project: Spark
>          Issue Type: Improvement
>          Components: SQL
>    Affects Versions: 4.4.0
>            Reporter: James Xu
>            Priority: Major
>
> Spark executes a bounded moving window frame by re-aggregating every row 
> currently inside the frame on every output row: 
> {{SlidingWindowFunctionFrame}} resets the aggregate buffer and re-folds the 
> whole frame for each row produced. For a window operator with nested sliding 
> frames over one (PARTITION BY, ORDER BY) key, the cost is the sum of the 
> frame widths (Σw) aggregate-update evaluations per output row, and the cost 
> is structural — every added horizon multiplies the work. There is no 
> incremental path: the window aggregate processor only supports 
> {{{}initialize{}}}/{{{}update{}}}/{{{}evaluate{}}}, with no {{{}remove{}}}, 
> so a moving frame cannot be advanced row-by-row.
> The pattern is common in user-facing metric tables that compute several 
> rolling horizons per measure over a daily partition ordering. A realistic 
> example:
> {code:sql}
> SELECT
>     seller_user_id,
>     sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
>                 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)   AS cnt_3d,
>     sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
>                 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)   AS cnt_7d,
>     sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
>                 ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)  AS cnt_30d,
>     sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
>                 ROWS BETWEEN 89 PRECEDING AND CURRENT ROW)  AS cnt_90d,
>     sum(cnt) OVER (PARTITION BY seller_user_id ORDER BY receive_date
>                 ROWS BETWEEN 179 PRECEDING AND CURRENT ROW) AS cnt_180d
>     -- ... repeated for several more measures
> FROM daily_aggregates{code}
> This shape measures 5 horizons, i.e. Σw = 3+7+30+90+180 = 310 
> aggregate-update evaluations per output row per measure family.
> In a production workload measured against this shape (a 3,437 CPU-hour 
> application), 45% of the CPU sits inside the window operator, and the window 
> stage's own input-sort spill reaches 14.8 TiB on the largest query.
> h3. Root cause
> {{SlidingWindowFunctionFrame.write}} re-runs the aggregate update for every 
> row in the frame on every output row. Growing frames are incremental and 
> offset frames are O(1), so the sliding frame is the only path with no O(1) 
> evaluation. The work factor is exactly Σw (sum of frame widths) per group per 
> row.
> h3. Proposed change
> Add an optimizer rule that rewrites each group of qualifying sliding frames 
> as one running aggregate plus offset differences:
> {code:java}
> -- Running total, reused as the base for every sliding window
> C = sum(x) OVER (PARTITION BY k ORDER BY d
>                 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
> -- n-row sliding sum == running total minus the running total n+1 rows back
> sum(x) OVER (PARTITION BY k ORDER BY d
>             ROWS BETWEEN n PRECEDING AND CURRENT ROW)
>     = C - coalesce(lag(C, n + 1) OVER (PARTITION BY k ORDER BY d), 0){code}
> For integral accumulators with ANSI off this is provably bit-identical: the 
> original fold already computes with wrapping addition, and modular reduction 
> commutes with subtraction, so the running prefix — a magnitude no bounded 
> frame can reach — overflows harmlessly. Floating-point accumulators 
> (non-associative addition) and decimal accumulators (overflow becomes a 
> silent NULL) are excluded, and a nullable measure carries a running count so 
> an all-NULL frame still returns NULL. The rewrite reuses the window 
> operator's own partition and order spec, so no new exchange or sort is 
> introduced.
> The rule is conservative: only non-distinct, unfiltered {{sum}} over integral 
> measures with literal {{n PRECEDING AND CURRENT ROW}} frames qualifies, 
> groups below a minimum total frame width are skipped, and it is gated behind 
> an opt-in configuration flag, default off.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to