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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -4761,6 +4761,17 @@ object SQLConf {
         "The threshold of window group limit must be -1, 0 or positive 
integer.")
       .createWithDefault(1000)
 
+  val COLLAPSE_WINDOW_WITH_EMPTY_ORDER_SPEC_IN_CHILD =
+    buildConf("spark.sql.optimizer.collapseWindowWithEmptyOrderSpecInChild")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .doc("When true, the optimizer collapses two adjacent windows with the 
same partition " +
+        "spec into one when the window with the empty order spec is the child 
(inner) window. " +
+        "This saves a WindowExec pass but can disable the WindowGroupLimit 
optimization for " +

Review Comment:
   **Finding 8.** The doc names only `WindowGroupLimit`, but the flag also 
costs `LimitPushDownThroughWindow`. Measured on this head with `SELECT c1, 
count(1) OVER () cnt, row_number() OVER (ORDER BY c2) rk FROM t3 LIMIT 5`:
   
   flag off (default):
   
       Project [c1, cnt, rk]
       +- Window [row_number() ... AS rk], [c2 ASC]
          +- GlobalLimit 5
             +- LocalLimit 5
                +- Sort [c2 ASC], true
                   +- Window [count(1) ... AS cnt]
   
   flag on:
   
       GlobalLimit 5
       +- LocalLimit 5
          +- Project [c1, cnt, rk]
             +- Window [count(1) ..., row_number() ...], [c2 ASC]
   
   So the top-5 `Limit + Sort` below the window is gone and the merged window 
sorts and buffers the whole input in a single partition.
   
   `LimitPushDownThroughWindow` sits *earlier* than `CollapseWindow` in the 
operator-optimization rule list, which is why I ruled it out last round, but it 
loses the race: the analyzed plan is 
`LocalLimit(Project(Project(Window(Window(...)))))`, and neither of its 
patterns (`LocalLimit(_, Window)`, `LocalLimit(_, Project(_, Window))`) matches 
through two projects. `CollapseProject` and `CollapseWindow` both run later in 
that same iteration, so by iteration 2 the pattern finally matches but 
`supportsPushdownThroughWindow` fails on the merged-in `count`.
   
   Nothing to fix in the rule -- both losses are the same "merged window is no 
longer all-expanding" effect and the flag is off by default. Just worth naming 
both, here and in the rule comment at `Optimizer.scala:1799`, so someone 
flipping this for the saved `WindowExec` pass knows what else goes:
   
   ```suggestion
           "This saves a WindowExec pass but can disable the WindowGroupLimit 
and the LocalLimit " +
           "push-down optimizations for top-k queries. " +
   ```
   



##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameWindowFunctionsSuite.scala:
##########
@@ -79,6 +79,27 @@ class DataFrameWindowFunctionsSuite extends 
SharedSparkSession
       parameters = Map("wf_name" -> "row_number", "wf_expr" -> "row_number()"))
   }
 
+  test("SPARK-58757: collapse window with an empty order spec into an ordered 
sibling") {
+    val df = Seq(
+      (0, 0), (0, 2), (0, 4),
+      (1, 1), (1, 3), (1, 5)).toDF("k", "v")
+    val ordered = Window.partitionBy("k").orderBy("v")
+    val unordered = Window.partitionBy("k")
+    checkAnswer(

Review Comment:
   **Finding 9.** `row_number` and `count` over a whole-partition frame are 
both order-insensitive, and there is no plan assertion, so this test cannot 
tell a merged plan from an unmerged one -- with 
`spark.sql.optimizer.excludedRules=org.apache.spark.sql.catalyst.optimizer.CollapseWindow`
 it still passes, on a plan that has two `Window` operators. As a regression 
test for this PR it can only catch a crash.
   
   The sibling collapse test in this file already has the idiom 
(`DataFrameWindowFunctionsSuite.scala:1716`):
   
   ```scala
   val windows = df.queryExecution.optimizedPlan.collect { case w: 
LogicalWindow => w }
   assert(windows.size === 1)
   ```
   
   Adding that plus an order-dependent function makes the test pin both the 
merge and the row order the merged-in expressions now see -- which is the 
interesting property, since the parent-empty direction is supposed to preserve 
it exactly. Verified this passes on this head:
   
   ```scala
       val res = df.select(
         $"k",
         $"v",
         row_number().over(ordered).as("rn"),
         collect_list($"v").over(unordered).as("vs"),
         first($"v").over(unordered).as("f"))
       assert(res.queryExecution.optimizedPlan.collect { case w: LogicalWindow 
=> w }.size === 1)
       checkAnswer(res, Seq(
         Row(0, 0, 1, Array(0, 2, 4), 0),
         Row(0, 2, 2, Array(0, 2, 4), 0),
         Row(0, 4, 3, Array(0, 2, 4), 0),
         Row(1, 1, 1, Array(1, 3, 5), 1),
         Row(1, 3, 2, Array(1, 3, 5), 1),
         Row(1, 5, 3, Array(1, 3, 5), 1)))
   ```
   



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/CollapseWindowSuite.scala:
##########
@@ -168,4 +174,220 @@ class CollapseWindowSuite extends PlanTest {
 
     comparePlans(optimized, correctAnswer)
   }
+
+  test("collapse windows when one has an empty order spec " +
+    "(row_number + count over the whole partition)") {
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, cnt), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window has multiple window 
expressions") {
+    // Every window expression of the empty-order window must be 
order-insensitive for the merge.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+    val sm = windowExpr(
+      AggregateExpression(Sum(b), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("sm")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt, sm), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, cnt, sm), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window has first() over the 
whole partition") {
+    // `first` is non-deterministic when the order is not determined by the 
query, so evaluating it
+    // under the other window's order spec yields a valid result.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val fr = windowExpr(
+      First(a, ignoreNulls = true).toAggregateExpression(),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("fr")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(fr), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, fr), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("don't collapse windows when the empty-order window has a bounded 
frame") {
+    // The frame `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` is 
order-sensitive: which rows
+    // fall in the frame depends on the ordering, so the window cannot be 
evaluated under the other
+    // window's order spec.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt), partitionSpec1, Nil)
+
+    val optimized = Optimize.execute(query.analyze)
+    val correctAnswer = query.analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window has a RANGE 
whole-partition frame") {
+    // `RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` covers the 
whole partition just
+    // like `ROWS`, so it also collapses.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RangeFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+      .window(Seq(cnt), partitionSpec1, Nil)
+
+    val analyzed = query.analyze
+    val optimized = Optimize.execute(analyzed)
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(rk, cnt), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows when the empty-order window is the inner window") {
+    // The empty-order window can also be the child of the ordered window. In 
that case its
+    // expressions are evaluated under the ordered window's order spec, which 
is valid because all
+    // of them are order-insensitive. This direction can disable 
InferWindowGroupLimit, so it is
+    // gated by `spark.sql.optimizer.collapseWindowWithEmptyOrderSpecInChild`.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(cnt), partitionSpec1, Nil)
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+
+    val analyzed = query.analyze
+    val optimized = withSQLConf(
+        SQLConf.COLLAPSE_WINDOW_WITH_EMPTY_ORDER_SPEC_IN_CHILD.key -> "true") {
+      Optimize.execute(analyzed)
+    }
+    assert(analyzed.output === optimized.output)
+
+    val correctAnswer = testRelation
+      .window(Seq(cnt, rk), partitionSpec1, orderSpec1)
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("don't collapse the inner empty-order window by default") {
+    // Merging an empty-order child into an ordered parent can disable 
InferWindowGroupLimit for
+    // top-k queries, so it is off by default.
+    val rk = windowExpr(
+      RowNumber(),
+      windowSpec(partitionSpec1, orderSpec1,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
CurrentRow))).as("rk")
+    val cnt = windowExpr(
+      AggregateExpression(Count(c), Complete, isDistinct = false, None),
+      windowSpec(partitionSpec1, Nil,
+        SpecifiedWindowFrame(RowFrame, UnboundedPreceding, 
UnboundedFollowing))).as("cnt")
+
+    val query = testRelation
+      .window(Seq(cnt), partitionSpec1, Nil)
+      .window(Seq(rk), partitionSpec1, orderSpec1)
+
+    val optimized = Optimize.execute(query.analyze)
+    val correctAnswer = query.analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("collapse windows with a Project between them when one has an empty 
order spec") {

Review Comment:
   **Finding 10.** This covers the `Project`-between pattern only in the 
config-gated direction, so the direction that is on by default has no test for 
that second `apply` case. Worth mirroring it -- verified this merges on this 
head with no config set:
   
   ```scala
       val query = testRelation
         .window(Seq(rk), partitionSpec1, orderSpec1)
         .select($"a", $"b", $"c", $"rk")
         .window(Seq(cnt), partitionSpec1, Nil)
         .select($"a", $"b", $"c", $"rk", $"cnt")
   ```
   
       Project [a#0, b#1, c#2, rk#4, cnt#6L]
       +- Window [row_number() ... AS rk#4, count(c#2) ... AS cnt#6L], [c#2], 
[c#2 ASC NULLS FIRST]
          +- LocalRelation <empty>, [a#0, b#1, c#2]
   



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