This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new ed150f9df74a [SPARK-57424][SQL] Add First/Last to segment-tree window
aggregate allowlist
ed150f9df74a is described below
commit ed150f9df74a6a8ee9caf72ea8d9fcd1729cc4d3
Author: Anupam Yadav <[email protected]>
AuthorDate: Thu Jul 9 10:10:03 2026 +0800
[SPARK-57424][SQL] Add First/Last to segment-tree window aggregate allowlist
### What changes were proposed in this pull request?
Add `classOf[First]` and `classOf[Last]` to
`WindowSegmentTree.EligibleAggregates`,
routing First/Last window aggregates through the segment-tree path
established
by SPARK-56546 (sliding) and SPARK-57220 (shrinking) instead of the legacy
O(N x W) sliding / O(N^2) shrinking frame implementations. No new frame
class,
no new SQLConf, no dispatcher changes -- the existing dispatcher branches in
`WindowEvaluatorFactoryBase` already gate on `eligibleForSegTree`, which
calls
`WindowSegmentTree.isEligible`.
### Why are the changes needed?
`First` and `Last` were previously denylisted as "order-dependent". This was
over-conservative: order-dependence in row-traversal order is exactly what
`WindowSegmentTree.query` provides. The query walks left-to-right (left
partial -> full blocks ascending -> right partial; within a block,
`queryDescend` walks children in ascending index order).
`First.mergeExpressions`
and `Last.mergeExpressions` are correct under that traversal -- they pick
the
row-order extreme across any contiguous range. For IGNORE NULLS the same
merge
is mode-agnostic: per-row `updateExpressions` only set `valueSet=true` on
non-null values, so a per-block partial of `(null, false)` for an all-NULL
block is correctly skipped when merged with a later non-null block.
JIRA: https://issues.apache.org/jira/browse/SPARK-57424
### Does this PR introduce _any_ user-facing change?
Yes -- when `spark.sql.window.segmentTree.enabled=true`, FIRST/LAST window
aggregates over sliding or shrinking ROWS/RANGE frames execute through the
segment-tree path instead of the legacy frame implementations. Same opt-in
conf (default off), same eligibility allowlist mechanism, same fallback
below
`minPartitionRows`, same SQLMetrics. No public API changes.
### How was this patch tested?
New tests, all differential against the legacy frame (segment-tree on vs
off):
* `WindowSegmentTreeAllowlistSuite`: routing tests for
`first / last / first_ignore_nulls / last_ignore_nulls`; the previous
"first/last falls through" negative tests are flipped; the mixed-allowlist
test now uses `collect_list` (still on the denylist).
* `SegmentTreeWindowFunctionSuite`: sliding First/Last respect-nulls and
ignore-nulls, all-NULL columns in both modes, a
stretches-of-consecutive-NULLs
case for the IGNORE NULLS merge path, and a multi-block case (block size
16, a
40-row partition with an all-NULL middle block, wide frame) that forces
the
cross-block left-to-right combine spine and the all-NULL block partial the
correctness argument depends on.
* `UnboundedFollowingSegmentTreeSuite`: shrinking First/Last respect-nulls
and
ignore-nulls plus an all-NULL column boundary case.
### Benchmark
`FirstLastSegmentTreeWindowBenchmark` (results checked in at
`sql/core/benchmarks/FirstLastSegmentTreeWindowBenchmark-results.txt`; Linux
x86_64, Intel Xeon Platinum 8259CL 2.50GHz, OpenJDK 17):
Sliding frame `[-1000, +1000]` at N=10K:
| Aggregate | Naive | Segtree | Speedup |
|---|---|---|---|
| FIRST respect-nulls | 439 ms | 97 ms | 4.5x |
| LAST respect-nulls | 540 ms | 86 ms | 6.3x |
| FIRST ignore-nulls | 535 ms | 88 ms | 6.1x |
| LAST ignore-nulls | 729 ms | 83 ms | 8.8x |
Shrinking frame `[CURRENT ROW, UNBOUNDED FOLLOWING]` at N=10K:
| Aggregate | Naive | Segtree | Speedup |
|---|---|---|---|
| FIRST respect-nulls | 2,190 ms | 78 ms | 28.0x |
| LAST respect-nulls | 2,175 ms | 86 ms | 25.4x |
| FIRST ignore-nulls | 2,433 ms | 71 ms | 34.5x |
| LAST ignore-nulls | 2,887 ms | 72 ms | 39.9x |
N-sweep on FIRST shrinking:
| N | Naive | Segtree | Speedup |
|---|---|---|---|
| 5K | 584 ms | 66 ms | 8.8x |
| 25K | 13,473 ms | 96 ms | 140.3x |
| 50K | 53,593 ms | 154 ms | 347.5x |
| 100K | -- | 224 ms | -- |
Naive at N=100K is omitted (extrapolated cost ~3-4 min/iter); segtree path
stays sub-second.
### Was this patch authored or co-authored using generative AI tooling?
Yes.
Closes #56485 from yadavay-amzn/firstlast-segtree.
Authored-by: Anupam Yadav <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
...FirstLastSegmentTreeWindowBenchmark-results.txt | 143 ++++++++++++++
.../sql/execution/window/WindowSegmentTree.scala | 29 ++-
.../FirstLastSegmentTreeWindowBenchmark.scala | 212 +++++++++++++++++++++
.../window/SegmentTreeWindowFunctionSuite.scala | 101 ++++++++++
.../UnboundedFollowingSegmentTreeSuite.scala | 53 ++++++
.../window/WindowSegmentTreeAllowlistSuite.scala | 28 +--
6 files changed, 538 insertions(+), 28 deletions(-)
diff --git
a/sql/core/benchmarks/FirstLastSegmentTreeWindowBenchmark-results.txt
b/sql/core/benchmarks/FirstLastSegmentTreeWindowBenchmark-results.txt
new file mode 100644
index 000000000000..9337dee15e6d
--- /dev/null
+++ b/sql/core/benchmarks/FirstLastSegmentTreeWindowBenchmark-results.txt
@@ -0,0 +1,143 @@
+================================================================================================
+Section A - FIRST sliding respect-nulls
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST sliding respect-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+FIRST sliding respect-nulls naive 439 450
9 0.0 42903.0 1.0X
+FIRST sliding respect-nulls segtree 97 99
1 0.1 9489.9 4.5X
+
+
+================================================================================================
+Section A - LAST sliding respect-nulls
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+LAST sliding respect-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+LAST sliding respect-nulls naive 540 542
2 0.0 52725.5 1.0X
+LAST sliding respect-nulls segtree 86 91
4 0.1 8412.8 6.3X
+
+
+================================================================================================
+Section A - FIRST sliding IGNORE NULLS
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST sliding ignore-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+FIRST sliding ignore-nulls naive 535 551
18 0.0 52204.9 1.0X
+FIRST sliding ignore-nulls segtree 88 88
1 0.1 8582.2 6.1X
+
+
+================================================================================================
+Section A - LAST sliding IGNORE NULLS
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+LAST sliding ignore-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+LAST sliding ignore-nulls naive 729 733
3 0.0 71238.0 1.0X
+LAST sliding ignore-nulls segtree 83 83
0 0.1 8077.1 8.8X
+
+
+================================================================================================
+Section B - FIRST shrinking respect-nulls
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST shrinking respect-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+-------------------------------------------------------------------------------------------------------------------------
+FIRST shrinking respect-nulls naive 2190 2212
20 0.0 213893.5 1.0X
+FIRST shrinking respect-nulls segtree 78 79
1 0.1 7648.5 28.0X
+
+
+================================================================================================
+Section B - LAST shrinking respect-nulls
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+LAST shrinking respect-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+LAST shrinking respect-nulls naive 2175 2187
8 0.0 212398.8 1.0X
+LAST shrinking respect-nulls segtree 86 90
3 0.1 8377.1 25.4X
+
+
+================================================================================================
+Section B - FIRST shrinking IGNORE NULLS
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST shrinking ignore-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+FIRST shrinking ignore-nulls naive 2433 2454
13 0.0 237596.6 1.0X
+FIRST shrinking ignore-nulls segtree 71 73
3 0.1 6885.1 34.5X
+
+
+================================================================================================
+Section B - LAST shrinking IGNORE NULLS
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+LAST shrinking ignore-nulls, N=10K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+LAST shrinking ignore-nulls naive 2887 2897
11 0.0 281963.0 1.0X
+LAST shrinking ignore-nulls segtree 72 74
1 0.1 7065.8 39.9X
+
+
+================================================================================================
+Section C - N=5K
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST shrinking frame, N=5K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+FIRST naive N=5K 584 592
8 0.0 114141.5 1.0X
+FIRST segtree N=5K 66 67
0 0.1 12925.3 8.8X
+
+
+================================================================================================
+Section C - N=25K (stress)
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST shrinking frame, N=25K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+FIRST naive N=25K 13473 13494
27 0.0 526301.8 1.0X
+FIRST segtree N=25K 96 99
5 0.3 3750.5 140.3X
+
+
+================================================================================================
+Section C - N=50K (stress, last naive run)
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST shrinking frame, N=50K rows: Best Time(ms) Avg Time(ms)
Stdev(ms) Rate(M/s) Per Row(ns) Relative
+------------------------------------------------------------------------------------------------------------------------
+FIRST naive N=50K 53593 54105
846 0.0 1046735.1 1.0X
+FIRST segtree N=50K 154 156
1 0.3 3012.0 347.5X
+
+
+================================================================================================
+Section C - N=100K (segtree-only, stress)
+================================================================================================
+
+OpenJDK 64-Bit Server VM 17.0.19+10-LTS on Linux
5.10.258-261.1043.amzn2int.x86_64
+Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
+FIRST shrinking frame, N=100K rows (segtree-only): Best Time(ms) Avg
Time(ms) Stdev(ms) Rate(M/s) Per Row(ns) Relative
+---------------------------------------------------------------------------------------------------------------------------------
+FIRST segtree N=100K 224
224 0 0.5 2188.5 1.0X
+
+
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/window/WindowSegmentTree.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/window/WindowSegmentTree.scala
index 27a773636134..cdc6556f1862 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/window/WindowSegmentTree.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/window/WindowSegmentTree.scala
@@ -25,7 +25,7 @@ import org.apache.spark.SparkException
import org.apache.spark.memory.{MemoryConsumer, MemoryMode, TaskMemoryManager}
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions._
-import org.apache.spark.sql.catalyst.expressions.aggregate.{Average, Count,
DeclarativeAggregate, Max, Min, StddevPop, StddevSamp, Sum, VariancePop,
VarianceSamp}
+import org.apache.spark.sql.catalyst.expressions.aggregate.{Average, Count,
DeclarativeAggregate, First, Last, Max, Min, StddevPop, StddevSamp, Sum,
VariancePop, VarianceSamp}
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.ExternalAppendOnlyUnsafeRowArray
import org.apache.spark.sql.types.DataType
@@ -572,20 +572,29 @@ private[window] object WindowSegmentTree {
/**
* Explicit allowlist of [[DeclarativeAggregate]] subclasses safe for
- * segment-tree execution. Safe iff combine semantics form a commutative
- * monoid on the partial-buffer representation (associativity +
- * compatibility with `mergeExpressions`):
+ * segment-tree execution. Safe iff combine semantics are correct under the
+ * left-to-right combine order produced by [[WindowSegmentTree.query]]
+ * (left partial -> full blocks ascending -> right partial; within a block,
+ * `queryDescend` walks children in ascending index order).
*
- * - [[Min]], [[Max]]: idempotent semilattice.
- * - [[Sum]], [[Count]]: additive monoid.
+ * - [[Min]], [[Max]]: idempotent semilattice (associative + commutative).
+ * - [[Sum]], [[Count]]: additive monoid (associative + commutative).
* - [[Average]]: sum + count, both additive monoids.
* - [[StddevPop]], [[StddevSamp]], [[VariancePop]], [[VarianceSamp]]:
* Welford (count, mean, M2) is associative -- see
* CentralMomentAgg.mergeExpressions.
+ * - [[First]], [[Last]]: order-dependent but correct under left-to-right
+ * combine. `First.mergeExpressions` is `if(valueSet.left, left, right)`
+ * and `Last.mergeExpressions` is `if(valueSet.right, right, left)`;
+ * under the left-to-right traversal both pick the row-order extreme
+ * across any contiguous range. `IGNORE NULLS` is also handled: per-row
+ * `updateExpressions` only sets `valueSet=true` on non-null values, so
+ * a per-block partial of `(null, false)` for an all-NULL block is
+ * correctly skipped when merged with a later non-null block.
*
* Intentionally excluded (tracked as follow-up): HyperLogLogPlusPlus /
- * ApproxCountDistinct (sketch-buffer interaction unaudited), First / Last
- * (order-dependent), CollectList / CollectSet (unbounded buffer growth),
+ * ApproxCountDistinct (sketch-buffer interaction unaudited),
+ * CollectList / CollectSet (unbounded buffer growth),
* Percentile / ApproxPercentile (sorted-sketch buffer), and any
* ImperativeAggregate (excluded by the type check).
*
@@ -600,7 +609,9 @@ private[window] object WindowSegmentTree {
classOf[StddevPop],
classOf[StddevSamp],
classOf[VariancePop],
- classOf[VarianceSamp]
+ classOf[VarianceSamp],
+ classOf[First],
+ classOf[Last]
)
/**
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/FirstLastSegmentTreeWindowBenchmark.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/FirstLastSegmentTreeWindowBenchmark.scala
new file mode 100644
index 000000000000..f1287419704a
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/FirstLastSegmentTreeWindowBenchmark.scala
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.benchmark
+
+import org.apache.spark.benchmark.Benchmark
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Benchmark for FIRST/LAST window aggregates over sliding and shrinking
+ * ROWS frames, comparing the legacy O(N x W) / O(N^2) frame paths against
+ * the segment-tree path enabled by adding `classOf[First]` / `classOf[Last]`
+ * to `WindowSegmentTree.EligibleAggregates`.
+ *
+ * Today's slow paths:
+ * - Sliding: `SlidingWindowFunctionFrame.write` rebuilds the per-row
+ * buffer aggregate by iterating `processor.update` over every row in
+ * the buffer (O(W) per output row, O(N*W) total).
+ * - Shrinking: `UnboundedFollowingWindowFunctionFrame.write` walks the
+ * remaining suffix on every output row (O(N^2) total; class scaladoc
+ * literally says O(n*(n-1)/2)).
+ *
+ * Sections:
+ * - A: FIRST/LAST per-mode at N=10K, sliding wide frame.
+ * - B: FIRST/LAST per-mode at N=10K, shrinking frame.
+ * - C: N-sweep for FIRST shrinking, naive vs segtree, demonstrating the
+ * algorithmic gap. Mirrors UnboundedFollowingWindowBenchmark layout.
+ */
+object FirstLastSegmentTreeWindowBenchmark extends SqlBasedBenchmark {
+
+ // Section A/B: per-mode per-frame-shape at calibrated N
+ private val AB_N: Long = 10L * 1024L
+
+ // Section C: N-sweep for shrinking FIRST
+ private val C_N_SMALL: Long = 5L * 1024L
+ private val C_N_MID: Long = 25L * 1024L
+ private val C_N_LARGE: Long = 50L * 1024L
+ private val C_N_HUGE: Long = 100L * 1024L
+
+ private val ITERS_NORMAL: Int = 5
+ private val ITERS_STRESS: Int = 3
+
+ // Sliding frame width tuned so the O(N*W) baseline is observable but not
+ // catastrophic. With N=10K and W=2001 the legacy SlidingWindowFunctionFrame
+ // performs ~20M update calls (10K rows x ~2K-row buffer rebuild on each
+ // boundary change) which is enough to expose the gap without dominating
+ // wall-clock at calibration time.
+ private val SLIDING_FRAME =
+ "OVER (ORDER BY id ROWS BETWEEN 1000 PRECEDING AND 1000 FOLLOWING)"
+ private val SHRINKING_FRAME =
+ "OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)"
+
+ override def runBenchmarkSuite(mainArgs: Array[String]): Unit = {
+ val smokeMode = mainArgs.nonEmpty
+ val smokeRowCount = if (smokeMode) mainArgs(0).toLong else 0L
+
+ def setupIntTable(n: Long): Unit = {
+ // Sprinkle ~10% NULLs so IGNORE NULLS exercises the merge path
+ // distinctly from respect-nulls; integer values otherwise.
+ spark.range(n)
+ .selectExpr("id",
+ "CASE WHEN rand(7) < 0.1 THEN NULL " +
+ "ELSE cast(rand(42) * 1000000 as int) END as v")
+ .coalesce(1)
+ .createOrReplaceTempView("t")
+ }
+
+ def rowsLabel(rows: Long): String = {
+ if (rows >= 1000000) s"${rows / 1000000}M"
+ else if (rows >= 1024) s"${rows / 1024}K"
+ else rows.toString
+ }
+
+ /**
+ * Equivalence digest. FIRST/LAST in respect-nulls mode are bit-exact
+ * across naive and segtree paths; in IGNORE NULLS the per-block merge
+ * also yields the same result row-for-row. We hash the result column
+ * directly (not COALESCE'd) so a NULL row hashes to NULL and a NULL
+ * sum is propagated, but the comparison still distinguishes shapes.
+ */
+ def digest(sql: String, sqlConfs: (String, String)*): Long = {
+ withSQLConf(sqlConfs: _*) {
+ val r = spark.sql(s"SELECT SUM(HASH(m)) FROM (SELECT $sql AS m FROM
t)")
+ .head().get(0)
+ if (r == null) 0L else r.asInstanceOf[Long]
+ }
+ }
+
+ def runCase(label: String, sql: String, iters: Int, rows: Long): Unit = {
+ val dNaive = digest(sql)
+ val dSeg = digest(sql, SQLConf.WINDOW_SEGMENT_TREE_ENABLED.key -> "true")
+ require(dNaive == dSeg,
+ s"$label digest mismatch: naive=$dNaive seg=$dSeg")
+
+ val benchmark = new Benchmark(
+ s"$label, N=${rowsLabel(rows)} rows", rows, output = output)
+ benchmark.addCase(s"$label naive", numIters = iters) { _ =>
+ spark.sql(s"SELECT $sql FROM t").noop()
+ }
+ benchmark.addCase(s"$label segtree", numIters = iters) { _ =>
+ withSQLConf(SQLConf.WINDOW_SEGMENT_TREE_ENABLED.key -> "true") {
+ spark.sql(s"SELECT $sql FROM t").noop()
+ }
+ }
+ benchmark.run()
+ }
+
+ def runSweepCase(rows: Long, includeNaive: Boolean, iters: Int): Unit = {
+ val sql = s"FIRST(v) $SHRINKING_FRAME"
+ if (includeNaive) {
+ val dNaive = digest(sql)
+ val dSeg = digest(sql, SQLConf.WINDOW_SEGMENT_TREE_ENABLED.key ->
"true")
+ require(dNaive == dSeg,
+ s"Section C N=${rowsLabel(rows)} digest mismatch: naive=$dNaive
seg=$dSeg")
+ }
+ val benchmark = new Benchmark(
+ s"FIRST shrinking frame, N=${rowsLabel(rows)} rows" +
+ (if (!includeNaive) " (segtree-only)" else ""),
+ rows, output = output)
+ if (includeNaive) {
+ benchmark.addCase(s"FIRST naive N=${rowsLabel(rows)}", numIters =
iters) { _ =>
+ spark.sql(s"SELECT $sql FROM t").noop()
+ }
+ }
+ benchmark.addCase(s"FIRST segtree N=${rowsLabel(rows)}", numIters =
iters) { _ =>
+ withSQLConf(SQLConf.WINDOW_SEGMENT_TREE_ENABLED.key -> "true") {
+ spark.sql(s"SELECT $sql FROM t").noop()
+ }
+ }
+ benchmark.run()
+ }
+
+ if (smokeMode) {
+ setupIntTable(smokeRowCount)
+ runBenchmark("SMOKE Section A FIRST sliding") {
+ runCase("FIRST sliding respect-nulls",
+ s"FIRST(v) $SLIDING_FRAME", ITERS_STRESS, smokeRowCount)
+ }
+ } else {
+ setupIntTable(AB_N)
+
+ // Section A: sliding frame, all four mode/function combinations.
+ runBenchmark("Section A - FIRST sliding respect-nulls") {
+ runCase("FIRST sliding respect-nulls",
+ s"FIRST(v) $SLIDING_FRAME", ITERS_NORMAL, AB_N)
+ }
+ runBenchmark("Section A - LAST sliding respect-nulls") {
+ runCase("LAST sliding respect-nulls",
+ s"LAST(v) $SLIDING_FRAME", ITERS_NORMAL, AB_N)
+ }
+ runBenchmark("Section A - FIRST sliding IGNORE NULLS") {
+ runCase("FIRST sliding ignore-nulls",
+ s"FIRST(v) IGNORE NULLS $SLIDING_FRAME", ITERS_NORMAL, AB_N)
+ }
+ runBenchmark("Section A - LAST sliding IGNORE NULLS") {
+ runCase("LAST sliding ignore-nulls",
+ s"LAST(v) IGNORE NULLS $SLIDING_FRAME", ITERS_NORMAL, AB_N)
+ }
+
+ // Section B: shrinking frame, all four mode/function combinations.
+ runBenchmark("Section B - FIRST shrinking respect-nulls") {
+ runCase("FIRST shrinking respect-nulls",
+ s"FIRST(v) $SHRINKING_FRAME", ITERS_NORMAL, AB_N)
+ }
+ runBenchmark("Section B - LAST shrinking respect-nulls") {
+ runCase("LAST shrinking respect-nulls",
+ s"LAST(v) $SHRINKING_FRAME", ITERS_NORMAL, AB_N)
+ }
+ runBenchmark("Section B - FIRST shrinking IGNORE NULLS") {
+ runCase("FIRST shrinking ignore-nulls",
+ s"FIRST(v) IGNORE NULLS $SHRINKING_FRAME", ITERS_NORMAL, AB_N)
+ }
+ runBenchmark("Section B - LAST shrinking IGNORE NULLS") {
+ runCase("LAST shrinking ignore-nulls",
+ s"LAST(v) IGNORE NULLS $SHRINKING_FRAME", ITERS_NORMAL, AB_N)
+ }
+
+ // Section C: shrinking-frame N-sweep on FIRST, demonstrating O(N^2)
+ // legacy vs O(N log N) segtree gap that widens with N.
+ setupIntTable(C_N_SMALL)
+ runBenchmark("Section C - N=5K") {
+ runSweepCase(C_N_SMALL, includeNaive = true, ITERS_NORMAL)
+ }
+ setupIntTable(C_N_MID)
+ runBenchmark("Section C - N=25K (stress)") {
+ runSweepCase(C_N_MID, includeNaive = true, ITERS_STRESS)
+ }
+ setupIntTable(C_N_LARGE)
+ runBenchmark("Section C - N=50K (stress, last naive run)") {
+ runSweepCase(C_N_LARGE, includeNaive = true, ITERS_STRESS)
+ }
+ setupIntTable(C_N_HUGE)
+ runBenchmark("Section C - N=100K (segtree-only, stress)") {
+ runSweepCase(C_N_HUGE, includeNaive = false, ITERS_STRESS)
+ }
+ }
+ }
+}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/window/SegmentTreeWindowFunctionSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/window/SegmentTreeWindowFunctionSuite.scala
index 5e644cceb142..5bf74c351c08 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/window/SegmentTreeWindowFunctionSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/window/SegmentTreeWindowFunctionSuite.scala
@@ -99,6 +99,19 @@ class SegmentTreeWindowFunctionSuite extends
SharedSparkSession {
baseDF.select($"id", $"pk", avg($"v").over(winSpec(-3, 3)).as("agg")))
}
+ // First / Last basic equivalence (respect-nulls; the default for
+ // first()/last()). Order-correctness depends on the segment-tree
+ // combine being left-to-right; see WindowSegmentTree.EligibleAggregates.
+ test("FIRST over ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING") {
+ checkEquivalence(() =>
+ baseDF.select($"id", $"pk", first($"v").over(winSpec(-3, 3)).as("agg")))
+ }
+
+ test("LAST over ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING") {
+ checkEquivalence(() =>
+ baseDF.select($"id", $"pk", last($"v").over(winSpec(-3, 3)).as("agg")))
+ }
+
test("MIN + MAX + SUM share a single window frame") {
checkEquivalence(() =>
baseDF.select(
@@ -229,6 +242,94 @@ class SegmentTreeWindowFunctionSuite extends
SharedSparkSession {
count($"v").over(winSpec(-4, 4)).as("cn")))
}
+ // First / Last respect-nulls: NULL is a valid value. If the first row in the
+ // frame is NULL, FIRST returns NULL. The seg-tree merge must preserve this.
+ test("FIRST/LAST respect-nulls with mixed NULL frame contents") {
+ val df = spark.range(0, 60).selectExpr(
+ "id",
+ "(id % 3) AS pk",
+ "CASE WHEN id % 3 = 0 THEN NULL ELSE CAST(id AS INT) END AS v")
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v").over(winSpec(-4, 4)).as("fv"),
+ last($"v").over(winSpec(-4, 4)).as("lv")))
+ }
+
+ // First / Last IGNORE NULLS: per-row updates only set valueSet on non-null
+ // values. A per-block partial of (null, false) for an all-NULL block must
+ // be correctly skipped when merged with a later non-null block.
+ test("FIRST/LAST ignore-nulls with mixed NULL frame contents") {
+ val df = spark.range(0, 60).selectExpr(
+ "id",
+ "(id % 3) AS pk",
+ "CASE WHEN id % 3 = 0 THEN NULL ELSE CAST(id AS INT) END AS v")
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v", ignoreNulls = true).over(winSpec(-4, 4)).as("fv_ign"),
+ last($"v", ignoreNulls = true).over(winSpec(-4, 4)).as("lv_ign")))
+ }
+
+ // All-NULL column edge case for First/Last in both modes.
+ // Respect-nulls: returns NULL. Ignore-nulls: also returns NULL (no
+ // non-null candidate ever sets valueSet).
+ test("all-NULL column: FIRST/LAST in both modes") {
+ val df = spark.range(0, 30).selectExpr(
+ "id", "(id % 3) AS pk", "CAST(NULL AS INT) AS v")
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v").over(winSpec(-3, 3)).as("fv"),
+ last($"v").over(winSpec(-3, 3)).as("lv"),
+ first($"v", ignoreNulls = true).over(winSpec(-3, 3)).as("fv_ign"),
+ last($"v", ignoreNulls = true).over(winSpec(-3, 3)).as("lv_ign")))
+ }
+
+ // Adversarial NULL distribution for IGNORE NULLS: per-block aggregates need
+ // to compose correctly when an entire block is all-NULL. With block size
+ // 65536 and partition size 120 we cannot literally produce a fully-NULL
+ // block via the standard fixture, but a long stretch of consecutive NULLs
+ // exercises the same merge path (per-row updates produce intermediate
+ // valueSet=false buffers which then merge with a later valueSet=true buffer
+ // via mergeExpressions). Combined with a wide frame to force tree queries
+ // crossing the all-NULL stretch.
+ test("FIRST/LAST ignore-nulls: stretches of consecutive NULLs cross-merge
correctly") {
+ val df = spark.range(0, 90).selectExpr(
+ "id",
+ "0 AS pk",
+ // First 30 rows non-null, next 30 all NULL, last 30 non-null again.
+ "CASE WHEN id BETWEEN 30 AND 59 THEN NULL ELSE CAST(id AS INT) END AS v")
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v", ignoreNulls = true).over(winSpec(-20, 20)).as("fv_ign"),
+ last($"v", ignoreNulls = true).over(winSpec(-20, 20)).as("lv_ign")))
+ }
+
+ // Multi-block First/Last: the correctness of First/Last on the segment tree
+ // relies on the combine being applied strictly left-to-right (First keeps
the
+ // left operand, Last the right). The default 65536 block size answers every
+ // small-partition query inside a single block (queryDescend only), so the
+ // cross-block combine spine (left partial -> ascending full blocks -> right
+ // partial in WindowSegmentTree.query) is never exercised by the fixtures
+ // above. Force blockSize=16 on a 40-row partition so a wide frame crosses
+ // three blocks, and make the middle block (rows 16..31) entirely NULL so the
+ // all-NULL (null, false) block partial is produced and merged for real --
the
+ // exact path the IGNORE NULLS rationale depends on. Differential vs the
+ // legacy frame in both respect-nulls and ignore-nulls modes.
+ test("FIRST/LAST multi-block combine with an all-NULL middle block") {
+ val df = spark.range(0, 40).selectExpr(
+ "id",
+ "0 AS pk",
+ // Blocks (size 16): [0,16) non-null, [16,32) all NULL, [32,40) non-null.
+ "CASE WHEN id BETWEEN 16 AND 31 THEN NULL ELSE CAST(id AS INT) END AS v")
+ withSegTreeBlock() {
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v").over(winSpec(-20, 20)).as("fv"),
+ last($"v").over(winSpec(-20, 20)).as("lv"),
+ first($"v", ignoreNulls = true).over(winSpec(-20, 20)).as("fv_ign"),
+ last($"v", ignoreNulls = true).over(winSpec(-20, 20)).as("lv_ign")))
+ }
+ }
+
test("Double NaN and +/-Infinity propagate correctly through MIN/MAX/SUM") {
// Trap: NaN > +Inf in Spark's MIN/MAX ordering; +Inf + -Inf = NaN in SUM.
// Seg-tree uses DeclarativeAggregate.merge; behavior must match baseline.
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/window/UnboundedFollowingSegmentTreeSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/window/UnboundedFollowingSegmentTreeSuite.scala
index 90df2240927b..67316ba7a048 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/window/UnboundedFollowingSegmentTreeSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/window/UnboundedFollowingSegmentTreeSuite.scala
@@ -117,6 +117,22 @@ class UnboundedFollowingSegmentTreeSuite extends
SharedSparkSession {
baseDF.select($"id", $"pk",
avg($"v").over(shrinkingRowsFrame(0)).as("agg")))
}
+ // First / Last over a shrinking frame: in respect-nulls mode, FIRST is just
+ // `rows[lower]` (the first row of the suffix). LAST advances with the
+ // shrinking lower bound but always sees the partition's end. Both modes
+ // exercise the segment-tree merge path through a series of `[lower, n)`
+ // queries; correctness depends on the same left-to-right combine that
+ // makes First/Last safe in the sliding case.
+ test("FIRST over ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING") {
+ checkEquivalence(() =>
+ baseDF.select($"id", $"pk",
first($"v").over(shrinkingRowsFrame(0)).as("agg")))
+ }
+
+ test("LAST over ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING") {
+ checkEquivalence(() =>
+ baseDF.select($"id", $"pk",
last($"v").over(shrinkingRowsFrame(0)).as("agg")))
+ }
+
// ============================================================
// ROWS frame: lower-bound variations
// ============================================================
@@ -240,6 +256,43 @@ class UnboundedFollowingSegmentTreeSuite extends
SharedSparkSession {
count($"v").over(shrinkingRowsFrame(0)).as("c")))
}
+ // First / Last over a shrinking frame with NULL distribution. Mirrors the
+ // sliding-suite NULL tests; verifies the merge path is correct when the
+ // lower-edge partial block of the segtree query crosses a NULL/non-NULL
+ // boundary.
+ test("FIRST/LAST over shrinking frame: respect-nulls with mixed NULLs") {
+ val df = spark.range(0, 60).selectExpr(
+ "id",
+ "(id % 3) AS pk",
+ "CASE WHEN id % 3 = 0 THEN NULL ELSE CAST(id AS INT) END AS v")
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v").over(shrinkingRowsFrame(0)).as("fv"),
+ last($"v").over(shrinkingRowsFrame(0)).as("lv")))
+ }
+
+ test("FIRST/LAST over shrinking frame: ignore-nulls with mixed NULLs") {
+ val df = spark.range(0, 60).selectExpr(
+ "id",
+ "(id % 3) AS pk",
+ "CASE WHEN id % 3 = 0 THEN NULL ELSE CAST(id AS INT) END AS v")
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v", ignoreNulls =
true).over(shrinkingRowsFrame(0)).as("fv_ign"),
+ last($"v", ignoreNulls =
true).over(shrinkingRowsFrame(0)).as("lv_ign")))
+ }
+
+ test("all-NULL column: FIRST/LAST shrinking frame in both modes") {
+ val df = spark.range(0, 30).selectExpr("id", "(id % 3) AS pk",
+ "CAST(NULL AS INT) AS v")
+ checkEquivalence(() =>
+ df.select($"id", $"pk",
+ first($"v").over(shrinkingRowsFrame(0)).as("fv"),
+ last($"v").over(shrinkingRowsFrame(0)).as("lv"),
+ first($"v", ignoreNulls =
true).over(shrinkingRowsFrame(0)).as("fv_ign"),
+ last($"v", ignoreNulls =
true).over(shrinkingRowsFrame(0)).as("lv_ign")))
+ }
+
test("mixed NULL and non-NULL: NULLs must not leak into MIN/MAX") {
val df = (0 until 60).map { i =>
val v: Option[Int] = if (i % 4 == 0) None else Some(i)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/window/WindowSegmentTreeAllowlistSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/window/WindowSegmentTreeAllowlistSuite.scala
index 236d38cc6a91..8dbb4ceecf1b 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/window/WindowSegmentTreeAllowlistSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/window/WindowSegmentTreeAllowlistSuite.scala
@@ -81,7 +81,13 @@ class WindowSegmentTreeAllowlistSuite
("stddev_pop", (c: org.apache.spark.sql.Column) => stddev_pop(c)),
("stddev_samp", (c: org.apache.spark.sql.Column) => stddev_samp(c)),
("var_pop", (c: org.apache.spark.sql.Column) => var_pop(c)),
- ("var_samp", (c: org.apache.spark.sql.Column) => var_samp(c))
+ ("var_samp", (c: org.apache.spark.sql.Column) => var_samp(c)),
+ ("first", (c: org.apache.spark.sql.Column) => first(c)),
+ ("last", (c: org.apache.spark.sql.Column) => last(c)),
+ ("first_ignore_nulls",
+ (c: org.apache.spark.sql.Column) => first(c, ignoreNulls = true)),
+ ("last_ignore_nulls",
+ (c: org.apache.spark.sql.Column) => last(c, ignoreNulls = true))
).foreach { case (name, fn) =>
test(s"$name routes to the segment-tree path") {
withSQLConf(enableSegTree.toSeq: _*) {
@@ -96,22 +102,6 @@ class WindowSegmentTreeAllowlistSuite
// Negative: non-allowlisted aggregates fall through
- test("first_value falls through (order-dependent aggregate)") {
- withSQLConf(enableSegTree.toSeq: _*) {
- val df = baseDF.withColumn("agg", first($"v").over(winSpec))
- val (seg, _) = segTreeCounters(df)
- assert(seg == 0, s"first_value should not use segment tree (got $seg
frames)")
- }
- }
-
- test("last_value falls through (order-dependent aggregate)") {
- withSQLConf(enableSegTree.toSeq: _*) {
- val df = baseDF.withColumn("agg", last($"v").over(winSpec))
- val (seg, _) = segTreeCounters(df)
- assert(seg == 0, s"last_value should not use segment tree (got $seg
frames)")
- }
- }
-
test("collect_list falls through (unbounded buffer)") {
withSQLConf(enableSegTree.toSeq: _*) {
val df = baseDF.withColumn("agg", collect_list($"v").over(winSpec))
@@ -176,10 +166,10 @@ class WindowSegmentTreeAllowlistSuite
withSQLConf(enableSegTree.toSeq: _*) {
val df = baseDF
.withColumn("s", sum($"v").over(winSpec))
- .withColumn("fv", first($"v").over(winSpec))
+ .withColumn("cl", collect_list($"v").over(winSpec))
val (seg, _) = segTreeCounters(df)
// Both aggregates share the same Window node; gating is
forall(isEligible),
- // so `first_value` drops the whole group.
+ // so `collect_list` (unbounded-buffer denylist) drops the whole group.
assert(seg == 0,
s"Window group containing a non-allowlisted agg must fall through (got
$seg)")
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]