peter-toth commented on code in PR #57346:
URL: https://github.com/apache/spark/pull/57346#discussion_r3860899086


##########
sql/core/benchmarks/WindowBenchmark-results.txt:
##########
@@ -2,171 +2,287 @@
 Section A - MIN (non-invertible)
 
================================================================================================
 
-OpenJDK 64-Bit Server VM 17.0.20+8-LTS on Linux 6.17.0-1020-azure
+OpenJDK 64-Bit Server VM 17.0.20+8-LTS on Linux 6.17.0-1022-azure
 AMD EPYC 7763 64-Core Processor
 MIN sliding window, W=1001, 256K rows:    Best Time(ms)   Avg Time(ms)   
Stdev(ms)    Rate(M/s)   Per Row(ns)   Relative
 
------------------------------------------------------------------------------------------------------------------------
-MIN naive (current, baseline)                      4014           4035         
 17          0.1       15313.6       1.0X
-MIN segtree (default)                               392            407         
 16          0.7        1496.8      10.2X
-MIN segtree (blockSize=256)                        2199           2214         
 12          0.1        8388.4       1.8X
+MIN naive (current, baseline)                      4036           4059         
 17          0.1       15396.6       1.0X
+MIN segtree (default)                               377            390         
 13          0.7        1439.8      10.7X
+MIN segtree (blockSize=256)                        2220           2226         
  6          0.1        8470.2       1.8X
+MIN monotonic deque (new)                           120            122         
  3          2.2         458.3      33.6X

Review Comment:
   **Finding 2.** This row and the ten other deque rows (`:26`, `:184`, `:196`, 
`:208`, `:221`, `:234`, `:247`, `:260`, `:273`, `:286`) were produced at 
`bf07c13`. `e02bbe4` then changed `SlidingWindowMinMaxFunctionFrame`, dropping 
a per-row `getNextOrNull` and a whole second pass over the row array on every 
ROWS frame. Every case in this file is a ROWS frame, since `frameFor` emits 
`ROWS BETWEEN $halfW PRECEDING AND $halfW FOLLOWING`, so the change touches 
every deque row here.
   
   I measured it rather than asking for a rerun, comparing this head against 
the same build with `needsLowerRow` forced back to `true` (5 iterations each, 
single window partition):
   
   | shape | with gate | pre-`e02bbe4` |
   |---|---|---|
   | Section A, MIN, 256K rows, W=1001 | 102 ms | 95 ms |
   | Section A, MAX, 256K rows, W=1001 | 75 ms | 78 ms |
   | Section H, MIN, 2M rows, W=11 | 334 ms | 346 ms |
   | disk-spilling ROWS, MIN, 200K rows, W=1001 | 130 ms | 138 ms |
   
   All within run-to-run noise, in both directions. So every number in this 
file stands and no conclusion moves; finding 23 was a dead-work claim, not a 
perf claim, and this confirms it.
   
   What is left is provenance: `sql/core/benchmarks/*-results.txt` is meant to 
be reproducible from the code at the same commit, and this is the third time 
the regeneration has landed ahead of the last code change (R3, R4, now). A full 
rerun is not worth it for a within-noise delta - Section B W=4001 naive alone 
is ~120 s per iteration. Two cheaper options: make the regeneration the last 
commit before merge, or add one line to the body saying which commit generated 
the file.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/window/WindowSegmentTreeAllowlistSuite.scala:
##########
@@ -42,7 +43,12 @@ class WindowSegmentTreeAllowlistSuite
 
   private val enableSegTree: Map[String, String] = Map(
     SQLConf.WINDOW_SEGMENT_TREE_ENABLED.key -> "true",
-    SQLConf.WINDOW_SEGMENT_TREE_MIN_PARTITION_ROWS.key -> "1")
+    SQLConf.WINDOW_SEGMENT_TREE_MIN_PARTITION_ROWS.key -> "1",
+    SQLConf.WINDOW_MONOTONIC_DEQUE_ENABLED.key -> "false")

Review Comment:
   **Finding 24.** This pin is right, and it is missing from the sibling suite. 
Every case in `SegmentTreeWindowMetricsSuite` is built on `min`/`max` 
(`:78-79`, `:94`, `:107`, `:134`, `:154`, `:172`, `:203`), and none of its 
seven `withSQLConf` blocks pins the new conf. With the deque on it intercepts 
all of them before `eligibleForSegTree` is consulted, so both segment-tree 
counters read 0.
   
   Measured: flip `WINDOW_MONOTONIC_DEQUE_ENABLED`'s default to `true` and run 
`sql/testOnly org.apache.spark.sql.execution.window.*` on this head. 153 
succeeded, 6 failed, and all 6 are in this one suite - it is the only suite in 
the package that breaks.
   
   ```
   - segment-tree path increments numSegmentTreeFrames (one per frame per 
partition) *** FAILED ***
     0 did not equal 3 expected 3 segtree frames (one per partition), got 
metrics =
     Map(number of segment-tree frames prepared -> 0, number of segment-tree 
fallback frames prepared -> 0)
   - fallback path increments numSegmentTreeFallbackFrames *** FAILED ***
   - T1 (G1) numPartitions > numTasks, identical length: every partition 
counted *** FAILED ***
   - T2 (G2) identical-length partitions across keys *** FAILED ***
   - T3 (G3) all-length-1 unique keys, fallback path: every partition counted 
*** FAILED ***
   - T4 (G4) mixed segtree + fallback, non-aliasing order *** FAILED ***
   ```
   
   Green today only because the conf defaults to `false`, so this is a landmine 
for whichever follow-up flips it - the same one you defused here. 
`SegmentTreeWindowFunctionSuite` and `UnboundedFollowingSegmentTreeSuite` are 
both fine, so it really is just this file.
   
   Fix: add `SQLConf.WINDOW_MONOTONIC_DEQUE_ENABLED.key -> "false"` to each of 
that suite's `withSQLConf` blocks (`:72`, `:89`, `:104`, `:127`, `:144`, 
`:164`, `:182`), or hoist the repeated prefix into a shared `val` the way you 
did here.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/window/MonotonicDequeWindowFunctionSuite.scala:
##########
@@ -0,0 +1,342 @@
+/*
+ * 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.window
+
+import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.expressions.Window
+import org.apache.spark.sql.functions._
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+
+/**
+ * Correctness tests verifying the monotonic deque-based sliding window frame 
optimization. Runs
+ * differential testing to ensure equivalence between:
+ *   1. Monotonic Deque (Enabled)
+ *   2. Segment Tree (Deque disabled, SegTree enabled)
+ *   3. Naive Baseline (Both disabled)
+ */
+class MonotonicDequeWindowFunctionSuite extends QueryTest with 
SharedSparkSession {
+
+  import testImplicits._
+
+  // Disable AQE so executedPlan.collect can descend into WindowExec without 
being
+  // blocked by AdaptiveSparkPlanExec (a LeafExecNode). This matches 
SegmentTreeWindowMetricsSuite.
+  private val enableDeque: Map[String, String] = Map(
+    SQLConf.WINDOW_MONOTONIC_DEQUE_ENABLED.key -> "true",
+    SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false")
+
+  private val disableDequeSegTree: Map[String, String] = Map(
+    SQLConf.WINDOW_MONOTONIC_DEQUE_ENABLED.key -> "false",
+    SQLConf.WINDOW_SEGMENT_TREE_ENABLED.key -> "true",
+    SQLConf.WINDOW_SEGMENT_TREE_MIN_PARTITION_ROWS.key -> "1")
+
+  private val disableDequeNaive: Map[String, String] = Map(
+    SQLConf.WINDOW_MONOTONIC_DEQUE_ENABLED.key -> "false",
+    SQLConf.WINDOW_SEGMENT_TREE_ENABLED.key -> "false")
+
+  /** Build `df` thrice (Deque, SegTree, Naive) and assert equal results. */
+  private def checkEquivalence(build: () => DataFrame, expectDeque: Boolean = 
true): Unit = {
+    val naiveResult: Seq[Row] = withSQLConf(disableDequeNaive.toSeq: _*) {
+      build().collect().toSeq
+    }
+    val segTreeResult: Seq[Row] = withSQLConf(disableDequeSegTree.toSeq: _*) {
+      build().collect().toSeq
+    }
+    val dequeResult: Seq[Row] = withSQLConf(enableDeque.toSeq: _*) {
+      val df = build()
+      val res = df.collect().toSeq
+
+      // Verify routing actually hit (or didn't hit) the deque.
+      // Use the registered metric key "numMonotonicDequeFrames" (not the 
display name).
+      val windowNodes = df.queryExecution.executedPlan.collect {
+        case w: WindowExec => w
+      }
+      assert(windowNodes.nonEmpty, "No WindowExec found in the query plan")
+      val dequeCount =
+        
windowNodes.flatMap(_.metrics.get("numMonotonicDequeFrames").map(_.value)).sum
+
+      if (expectDeque) {
+        assert(dequeCount > 0, "Monotonic deque was enabled but no frames were 
routed to it")
+      } else {
+        assert(dequeCount == 0, "Monotonic deque was used but expected to 
fallback")
+      }
+      res
+    }
+
+    QueryTest.sameRows(naiveResult, dequeResult, isSorted = false).foreach { 
err =>
+      fail(s"Monotonic Deque output differs from Naive baseline.\n$err")
+    }
+    QueryTest.sameRows(segTreeResult, dequeResult, isSorted = false).foreach { 
err =>
+      fail(s"Monotonic Deque output differs from Segment Tree baseline.\n$err")
+    }
+  }
+
+  private def baseDF: DataFrame = {
+    spark
+      .range(0, 100)
+      .selectExpr(
+        "id",
+        "(id % 3) AS pk",
+        "CAST(id AS INT) AS v_int",
+        "CAST(id AS LONG) AS v_long",
+        "CAST(id AS DOUBLE) AS v_double",
+        "CAST(id AS STRING) AS v_str")
+  }
+
+  test("SPARK-58201: Moving rows frame: MIN/MAX on primitives 
(Int/Long/Double)") {
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-3, 2)
+    checkEquivalence(() =>
+      baseDF.select(
+        $"id",
+        min($"v_int").over(winSpec),
+        max($"v_int").over(winSpec),
+        min($"v_long").over(winSpec),
+        max($"v_long").over(winSpec),
+        min($"v_double").over(winSpec),
+        max($"v_double").over(winSpec)))
+  }
+
+  test("SPARK-58201: Fallback for mixed aggregates (SUM + MIN/MAX)") {
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-4, 2)
+    checkEquivalence(() =>
+      baseDF.select(
+        $"id",
+        min($"v_int").over(winSpec),
+        sum($"v_int").over(winSpec)),
+      expectDeque = false)
+  }
+
+  test("SPARK-58201: Fallback for FILTER clauses") {
+    val df = spark.sql("""SELECT id,
+        |  MIN(id) FILTER (WHERE id % 2 = 0) OVER (
+        |    PARTITION BY (id % 3) ORDER BY id ROWS BETWEEN 2 PRECEDING AND 2 
FOLLOWING
+        |  ) AS v
+        |FROM RANGE(0, 20)""".stripMargin)
+    // Deque shouldn't be used since FILTER is not supported. We can't use 
checkEquivalence
+    // because checkEquivalence builds DF inside, so we'll just check metrics.
+    withSQLConf(
+      SQLConf.WINDOW_MONOTONIC_DEQUE_ENABLED.key -> "true",
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val res = df.collect()
+      val windowNodes = df.queryExecution.executedPlan.collect {
+        case w: WindowExec => w
+      }
+      val dequeCount =
+        
windowNodes.flatMap(_.metrics.get("numMonotonicDequeFrames").map(_.value)).sum
+      assert(dequeCount == 0, "Monotonic deque was used for FILTER clause")
+    }
+  }
+
+  test("SPARK-58201: Moving rows frame: MIN/MAX on reference types (String)") {
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-2, 3)
+    checkEquivalence(() =>
+      baseDF.select($"id", min($"v_str").over(winSpec), 
max($"v_str").over(winSpec)))
+  }
+
+  test("SPARK-58201: MIN/MAX on Date and Timestamp types") {
+    val df = baseDF.selectExpr(
+      "id",
+      "pk",
+      "CAST(id * 24 * 3600 AS TIMESTAMP) AS v_ts",
+      "date_add(to_date('1970-01-01'), CAST(id AS INT)) AS v_date")
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-2, 2)
+    checkEquivalence(() =>
+      df.select(
+        $"id",
+        min($"v_ts").over(winSpec),
+        max($"v_ts").over(winSpec),
+        min($"v_date").over(winSpec),
+        max($"v_date").over(winSpec)))
+  }
+
+  test("SPARK-58201: MIN/MAX on Interval types (YearMonthIntervalType and 
DayTimeIntervalType)") {
+    val df = baseDF.selectExpr(
+      "id",
+      "pk",
+      "make_ym_interval(0, CAST(id AS INT)) AS v_ym",
+      "make_dt_interval(CAST(id AS INT), 0, 0, 0) AS v_dt")
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-3, 3)
+    checkEquivalence(() =>
+      df.select(
+        $"id",
+        min($"v_ym").over(winSpec),
+        max($"v_ym").over(winSpec),
+        min($"v_dt").over(winSpec),
+        max($"v_dt").over(winSpec)))
+  }
+
+  test("SPARK-58201: MIN/MAX with null values in partition") {
+    val df = spark
+      .range(0, 50)
+      .selectExpr("id", "(id % 2) AS pk", "IF(id % 5 == 0, null, CAST(id AS 
INT)) AS v")
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-2, 2)
+    checkEquivalence(() => df.select($"id", min($"v").over(winSpec), 
max($"v").over(winSpec)))
+  }
+
+  test("SPARK-58201: MIN/MAX on all-null partition") {
+    val df = spark.range(0, 20).selectExpr("id", "1 AS pk", "CAST(null AS INT) 
AS v")
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-2, 2)
+    checkEquivalence(() => df.select($"id", min($"v").over(winSpec), 
max($"v").over(winSpec)))
+  }
+
+  test("SPARK-58201: Range-based moving frame: MIN/MAX on primitive types") {
+    val df = baseDF.selectExpr("id", "pk", "CAST(id / 2 AS INT) AS ord_val", 
"v_int")
+    val winSpec = 
Window.partitionBy($"pk").orderBy($"ord_val").rangeBetween(-2, 2)
+    checkEquivalence(() =>
+      df.select($"id", min($"v_int").over(winSpec), 
max($"v_int").over(winSpec)))
+  }
+
+  // Verify strict inequality preserves first-of-equals behavior
+  // under collated strings and signed zero.
+
+  test("SPARK-58201: MIN/MAX on collated strings (UTF8_LCASE) preserves 
first-of-equals") {
+    // Under UTF8_LCASE, 'Bob' and 'bob' compare equal. MIN must keep the
+    // first occurrence (lowest index), matching naive/segment-tree semantics.
+    val df = spark.sql("""SELECT id, 1 AS pk,
+        |  CASE WHEN id = 0 THEN COLLATE('Bob', 'UTF8_LCASE')
+        |       WHEN id = 1 THEN COLLATE('bob', 'UTF8_LCASE')
+        |       WHEN id = 2 THEN COLLATE('alice', 'UTF8_LCASE')
+        |       WHEN id = 3 THEN COLLATE('BOB', 'UTF8_LCASE')
+        |       ELSE COLLATE(CAST(id AS STRING), 'UTF8_LCASE')
+        |  END AS v
+        |FROM RANGE(0, 20)""".stripMargin)
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-2, 2)
+    checkEquivalence(() => df.select($"id", min($"v").over(winSpec), 
max($"v").over(winSpec)))
+  }
+
+  test("SPARK-58201: MIN/MAX on Double with signed zero (+0.0 / -0.0)") {
+    // SQLOrderingUtil.compareDoubles treats -0.0 == +0.0, so they compare
+    // equal. MIN must keep the first occurrence, matching naive semantics.
+    val df = spark
+      .range(0, 20)
+      .selectExpr(
+        "id",
+        "1 AS pk",
+        """CASE
+           WHEN id % 4 = 0 THEN CAST(-0.0 AS DOUBLE)
+           WHEN id % 4 = 1 THEN CAST(0.0 AS DOUBLE)
+           WHEN id % 4 = 2 THEN CAST(id AS DOUBLE)
+           ELSE CAST(-id AS DOUBLE)
+         END AS v""")
+    val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-2, 2)
+    checkEquivalence(() => df.select($"id", min($"v").over(winSpec), 
max($"v").over(winSpec)))
+  }
+
+  // Spill coverage: lower thresholds to force ExternalAppendOnlyUnsafeRowArray
+  // to use its SpillableArrayIterator, which recycles a single UnsafeRow.
+
+  test("SPARK-58201: Moving rows frame: MIN/MAX on reference types (String) 
with spill") {
+    withSQLConf(
+      SQLConf.WINDOW_EXEC_BUFFER_IN_MEMORY_THRESHOLD.key -> "8",
+      SQLConf.WINDOW_EXEC_BUFFER_SPILL_THRESHOLD.key -> "16") {
+      val winSpec = Window.partitionBy($"pk").orderBy($"id").rowsBetween(-2, 3)

Review Comment:
   **Finding 25.** These four spill tests (`:244`, `:254`, `:266`, `:321`) all 
use `rowsBetween`, and after `e02bbe4` a ROWS frame no longer opens a lower 
cursor. So they now exercise one `SpillableArrayIterator`, not two, and the 
only two RANGE tests (`:200`, `:313`) run in memory. Nothing in the suite 
covers the combination any more.
   
   That combination is the subtlest thing in the frame. A 
`SpillableArrayIterator` rebinds a single reusable `UnsafeRow` on every 
`next()`, and `write` holds `nextRow` across an advance of `lowerIterator`:
   
   ```scala
   while (nextRow != null && ubound.compare(nextRow, upperBound, current, 
index) <= 0) {
     if (lbound.compare(nextRow, lowerBound, current, index) < 0) {
       lowerBound += 1
       if (needsLowerRow) lowerRow = 
WindowFunctionFrame.getNextOrNull(lowerIterator)  // <-- advances a peer
     } else {
       ... deques(di).admit(nextRow, upperBound)                                
       // <-- still reads nextRow
   ```
   
   It is safe because each `SpillableArrayIterator` allocates its own 
`currentRow` 
(`sql/core/src/main/scala/org/apache/spark/sql/execution/ExternalAppendOnlyUnsafeRowArray.scala:243`)
 and `UnsafeExternalSorter.getIterator(startIndex)` builds an independent 
reader chain rather than reusing a shared one. Both are facts about another 
class, and the pre-`e02bbe4` code was the thing that pinned them.
   
   I verified the behaviour rather than just asserting the gap: with 
`WINDOW_EXEC_BUFFER_IN_MEMORY_THRESHOLD=4` and 
`WINDOW_EXEC_BUFFER_SPILL_THRESHOLD=8`, RANGE ascending, RANGE descending, and 
a multi-column `RANGE BETWEEN CURRENT ROW AND CURRENT ROW` all match the naive 
baseline, as does a sweep of 10 frame shapes x {ROWS, RANGE} x {spill, no 
spill}. So this is coverage, not a bug.
   
   One test closes it:
   
   ```scala
     test("SPARK-58201: Range-based moving frame: MIN/MAX with spill") {
       val df = baseDF.selectExpr("id", "pk", "CAST(id / 2 AS INT) AS ord_val", 
"v_int")
       withSQLConf(
         SQLConf.WINDOW_EXEC_BUFFER_IN_MEMORY_THRESHOLD.key -> "8",
         SQLConf.WINDOW_EXEC_BUFFER_SPILL_THRESHOLD.key -> "16") {
         val winSpec = 
Window.partitionBy($"pk").orderBy($"ord_val").rangeBetween(-2, 2)
         checkEquivalence(() =>
           df.select($"id", min($"v_int").over(winSpec), 
max($"v_int").over(winSpec)))
       }
     }
   ```
   



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