[SPARK-59658][SQL] Rewrite sliding window frames as prefix differences - #58926
Open
xumingming wants to merge 1 commit into
Open
xumingming wants to merge 1 commit into
xumingming wants to merge 1 commit into
Conversation
`WindowExec` re-aggregates every row inside a bounded frame on every
output row, so a group of sliding frames over one (PARTITION BY,
ORDER BY) key costs the sum of the frame widths aggregate updates per
output row. Rewrite each qualifying group as a single running sum over
the whole prefix plus lag differences:
C = sum(x) OVER (PARTITION BY k ORDER BY d
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
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)
Every frame then costs O(1), and the plan shape is preserved: both
window operators share the same partition, order and sort, so no
additional exchange or sort is introduced.
Safety is decided by a certificate per aggregate class rather than by
inspecting expressions:
- Only `sum` over an integral (byte, short, int, long) column in a
legacy, ANSI-off session qualifies. Its accumulator wraps modulo
2^64, and modular reduction commutes with subtraction, so the
difference is bit-identical to the frame fold even when the running
prefix - which can reach magnitudes no bounded frame reaches -
overflows.
- Floating-point (addition is not associative) and decimal (an
overflowed prefix silently becomes NULL) sums never qualify, and
neither does anything running under ANSI, which would throw on the
newly reachable prefix magnitudes.
- Distinct and FILTERed sums, non-`sum` aggregates, RANGE frames,
growing frames and frames without ORDER BY are left untouched.
- A nullable measure gets a running count guard, so that an all-NULL
frame keeps returning NULL instead of a zero difference.
- Groups whose total frame width is below the internal cost threshold
are not rewritten.
The rewrite is off by default and is switched on with
`spark.sql.optimizer.windowPrefixRewrite.enabled`.
Tests: `RewriteSlidingFramesAsPrefixDifferencesSuite` pins the emitted
plans (shape, per-group naming, per-candidate lag offsets, partial
rewrites, idempotence) and every negative case, while
`WindowPrefixRewriteQuerySuite` differentially checks results with the
config off and on, including nullability, short partitions, duplicate
(PARTITION BY, ORDER BY) keys, 64-bit overflow, ANSI sessions, and that
no exchange or sort is added.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
Add a new logical optimizer rule,
RewriteSlidingFramesAsPrefixDifferences, that rewrites agroup of sliding
ROWS BETWEEN n PRECEDING AND CURRENT ROWframes ofsumsharing one(PARTITION BY, ORDER BY)key into a single running sum plus lag differences:For a query with two rolling horizons over a serving log:
the logical plan goes from
to
(
-7/-30is howLagprints its frame offset, i.e. 7 / 30 rows back.)Properties that matter for review:
existing
WindowExec, so there is nothing new to maintain in the execution layer.so they share one sort and no exchange or sort is added (asserted by a test).
measures produce several groups, named
window_prefix_sum_<i>/window_prefix_count_<i>.not dropped.
countguard (CASE WHEN c - lag(c, w) = 0 THEN NULL ELSE ...),so an all-NULL frame keeps returning NULL rather than 0.
MIN_TOTAL_FRAME_WIDTH, 16 rows)are not rewritten; bench section D locates the naive-vs-rewrite crossover.
CollapseWindowin theOperator combinebatch. It is also registered in
RuleIdCollection.Safety is a default-deny certificate per aggregate class rather than an expression inspection:
only
sumover an integral (byte/short/int/long) measure in a legacy, ANSI-off sessionqualifies, because its accumulator is a
LongTypebuffer updated with wrappingAdd, i.e.arithmetic in Z/2^64Z where modular reduction commutes with subtraction. The difference is
therefore bit-identical to the frame fold even when the running prefix, which can reach
magnitudes no bounded frame ever reaches, overflows (covered by a test). Excluded: floating
point (addition is not associative), decimal (an overflowed prefix silently becomes NULL under
CheckOverflowInSum), anything running under ANSI (the newly reachable prefix magnitudes wouldthrow),
try_sum, distinct and FILTERed sums, non-sumaggregates, RANGE frames, growingframes, frames without ORDER BY, and window expressions whose spec differs from the window
operator's own spec.
The rewrite is opt-in and off by default:
It declares
ConfigBindingPolicy.NOT_APPLICABLE, since it changes only plan shape and does notaffect view/UDF/procedure resolution.
Why are the changes needed?
Spark evaluates a bounded moving frame by re-aggregating every row inside it on every output
row:
SlidingWindowFunctionFrame.writeresets the aggregate buffer and re-folds the wholeframe. For nested sliding frames over one
(PARTITION BY, ORDER BY)key the cost is the sum ofthe frame widths, 3+7+30+90+180 = 310 aggregate updates per output row per measure with the
usual 3d/7d/30d/90d/180d horizon set, and it grows with every horizon added. There is no
incremental path today: the window aggregate processor supports only
initialize/update/evaluate, with noremove, so a moving frame cannot be advancedrow-by-row. Growing frames are already incremental and offset frames are O(1); the sliding frame
is the one remaining unbounded-per-row path. This shape is common in user-facing metric tables
that compute several rolling horizons per measure, and in the workload that motivated this
change the window operator was ~45% of the CPU of a 3,437 CPU-hour application, with the window
stage's own input sort spilling ~14.8 TiB on the largest query.
The rewrite makes every frame O(1) while leaving the plan's parallelism and sort structure
untouched (
WindowPrefixRewriteBenchmark, results committed alongside):This is complementary to the executor-side block-chunked segment tree (SPARK-56546) rather than
a replacement: the segment tree is O(log W) per frame and accepts any
DeclarativeAggregatewithout FILTER/DISTINCT, while this rewrite is O(1) per frame but restricted to invertible
sumover integral, ANSI-off measures. With both enabled, a rewritten group has no movingframes left for the segment tree to handle.
Does this PR introduce any user-facing change?
Yes, a new feature, but no change for users who do not opt in.
spark.sql.optimizer.windowPrefixRewrite.enabledis new and defaults tofalse(new in4.4.0, so there is no behavior change relative to any released version). When enabled,
qualifying queries return exactly the same rows, the rewrite is bit-identical for the aggregate
classes it accepts, but
EXPLAINshows the running sum plus lag-difference shape instead of thesliding frame, and the window operator performs O(1) work per frame instead of O(W).
How was this patch tested?
Two suites plus a benchmark, all added by this PR.
RewriteSlidingFramesAsPrefixDifferencesSuite(catalyst,PlanTest) pins the emitted plans andevery rejection path: the emitted running sum plus per-candidate lag offsets and group naming,
several measure groups in one window operator, the NULL guard for nullable measures, the cost
gate boundary (width 15 no, width 16 yes), partial rewrites (both for non-
summembers and forsums that fail the safety certificate), idempotence, that emitted frames are ROWS and neverRANGE, and that no negative case is rewritten (filtered, distinct, float, decimal,
try_sum,ANSI, min/max, no ORDER BY, RANGE, growing, spec mismatch).
WindowPrefixRewriteQuerySuite(sql/core) differentially runs each query with the config offand on and compares the full projection, covering multiple widths/measures/nullability,
duplicate
(PARTITION BY, ORDER BY)keys, partitions shorter than the frame width, all-NULLmeasures, tinyint/smallint/int measures, 64-bit prefix overflow (bit-identical results), ANSI
sessions, and that the rewrite adds no exchange or sort; it ends with a randomized differential
test.
The benchmark is runnable from a single command, and the committed results file is generated
from it:
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Pi