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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1761,9 +1761,39 @@ object CollapseWindow extends Rule[LogicalPlan] {
       s1.zip(s2).forall(e => e._1.semanticEquals(e._2))
   }
 
+  /**
+   * Returns true if the given window expression can still be evaluated 
correctly when the rows
+   * of the partition are reordered, so that it can be merged into another 
window with a different
+   * (non-empty) order spec.
+   *
+   * The frame determines whether reordering is safe. When the frame is the 
whole partition
+   * (`UNBOUNDED PRECEDING` to `UNBOUNDED FOLLOWING`), it always covers all 
the rows of the
+   * partition regardless of the ordering, so reordering changes only the 
order in which the rows
+   * are seen, never which rows are in the frame. Since the order spec of the 
window is empty,
+   * the query does not fix the row order, so evaluating its expressions under 
any ordering
+   * yields a valid result, even though the value may differ for 
order-dependent expressions
+   * such as `first`, `collect_list`, or floating-point `sum`/`avg`. On the 
other hand, a bounded
+   * frame (e.g. `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`) is 
order-sensitive: which
+   * rows are in the frame depends on the ordering, so even `count` or `sum` 
would change value,
+   * and such a window must not be merged.
+   */
+  private def canEvaluateUnderAnyOrder(windowExpression: NamedExpression): 
Boolean =
+    windowExpression match {
+      case Alias(WindowExpression(_, WindowSpecDefinition(_, _,
+          SpecifiedWindowFrame(_, UnboundedPreceding, UnboundedFollowing))), 
_) => true
+      case _ => false
+    }
+
   private def windowsCompatible(w1: Window, w2: Window): Boolean = {
     specCompatible(w1.partitionSpec, w2.partitionSpec) &&
-      specCompatible(w1.orderSpec, w2.orderSpec) &&
+      // The order specs can differ when one of them is empty, as long as the 
window expressions
+      // of the window with the empty order spec are safe to evaluate under 
any row order. In that
+      // case, they can be evaluated under the non-empty order spec of the 
other window.
+      (specCompatible(w1.orderSpec, w2.orderSpec) ||

Review Comment:
   **Finding 1.** `InferWindowGroupLimit.isExpandingWindow` requires *every* 
window expression to carry `(RowFrame, UnboundedPreceding, CurrentRow)`:
   
   ```scala
   // InferWindowGroupLimit.scala:78
   case Alias(WindowExpression(windowFunction, WindowSpecDefinition(_, _,
   SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))), _)
     if !windowFunction.isInstanceOf[SizeBasedWindowFunction] => true
   ```
   
   `canEvaluateUnderAnyOrder` admits exactly the opposite upper bound, so any 
window this relaxation produces necessarily fails 
`windowExpressions.forall(isExpandingWindow)` at 
`InferWindowGroupLimit.scala:95` -- and that rule runs in `SparkOptimizer`'s 
"Infer window group limit" batch, i.e. after this one.
   
   The shape is reachable. `ExtractWindowExpressions` folds its `LinkedHashMap` 
in insertion order (`Analyzer.scala:3677`), so the first window spec in the 
select list becomes the innermost `Window`; writing the unordered aggregate 
first makes the ordered window the parent, which is what the `Filter` sits on. 
Measured on this head with a catalyst-only `RuleExecutor` over `Filter(rn <= 2, 
Window(rn, [c], [a desc], Window(cnt, [c], Nil, t)))`:
   
   ```
   CollapseWindow excluded:  WindowGroupLimit [c#2], [a#0 DESC], row_number(), 2
   CollapseWindow enabled:   <no WindowGroupLimit>
   ```
   
   Trading a per-partition early stop for one saved `WindowExec` pass is the 
wrong way round on a large partition. Relaxing `isExpandingWindow` is not the 
fix -- `WindowGroupLimit` drops rows *below* the window, which would change 
`count(1) OVER (PARTITION BY k)`.
   
   The cheap fix is to drop the child-empty branch and keep only the 
parent-empty one:
   
   ```scala
         (specCompatible(w1.orderSpec, w2.orderSpec) ||
           (w1.orderSpec.isEmpty && w2.orderSpec.nonEmpty &&
             w1.windowExpressions.forall(canEvaluateUnderAnyOrder))) &&
   ```
   
   That direction cannot lose a `WindowGroupLimit`: the `Filter` then sits on 
the empty-order window, which already fails `orderSpec.nonEmpty` on base, so 
there is nothing to lose. It keeps the motivating example and the benchmark 
(`row_number()` written before `count(1)` puts the empty-order window on top), 
and it also settles the order-change question in @zml1206's thread -- see 
finding 3. If you want the child-empty direction too, it needs a conf so a 
regressed query has an escape hatch.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1761,9 +1761,39 @@ object CollapseWindow extends Rule[LogicalPlan] {
       s1.zip(s2).forall(e => e._1.semanticEquals(e._2))
   }
 
+  /**

Review Comment:
   **Finding 6.** The object scaladoc just above (`Optimizer.scala:1753-1757`) 
still states the old precondition:
   
   ```
    * Collapse Adjacent Window Expression.
    * - If the partition specs and order specs are the same and the window 
expression are
    *   independent and are of the same window function type, collapse into the 
parent.
   ```
   
   Worth extending with the empty-order case -- that is the doc a reader hits 
first.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1761,9 +1761,39 @@ object CollapseWindow extends Rule[LogicalPlan] {
       s1.zip(s2).forall(e => e._1.semanticEquals(e._2))
   }
 
+  /**
+   * Returns true if the given window expression can still be evaluated 
correctly when the rows
+   * of the partition are reordered, so that it can be merged into another 
window with a different
+   * (non-empty) order spec.
+   *
+   * The frame determines whether reordering is safe. When the frame is the 
whole partition
+   * (`UNBOUNDED PRECEDING` to `UNBOUNDED FOLLOWING`), it always covers all 
the rows of the
+   * partition regardless of the ordering, so reordering changes only the 
order in which the rows
+   * are seen, never which rows are in the frame. Since the order spec of the 
window is empty,
+   * the query does not fix the row order, so evaluating its expressions under 
any ordering
+   * yields a valid result, even though the value may differ for 
order-dependent expressions
+   * such as `first`, `collect_list`, or floating-point `sum`/`avg`. On the 
other hand, a bounded
+   * frame (e.g. `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`) is 
order-sensitive: which
+   * rows are in the frame depends on the ordering, so even `count` or `sum` 
would change value,
+   * and such a window must not be merged.
+   */
+  private def canEvaluateUnderAnyOrder(windowExpression: NamedExpression): 
Boolean =
+    windowExpression match {
+      case Alias(WindowExpression(_, WindowSpecDefinition(_, _,
+          SpecifiedWindowFrame(_, UnboundedPreceding, UnboundedFollowing))), 
_) => true

Review Comment:
   **Finding 7.** The frame type is left free here, so `RANGE BETWEEN UNBOUNDED 
PRECEDING AND UNBOUNDED FOLLOWING` merges as well. That is legal with an empty 
order spec -- `WindowSpecDefinition.checkInputDataTypes` only rejects a 
`RangeFrame` that is *not* unbounded (`windowExpressions.scala:74`) -- and it 
is correct, because `WindowEvaluatorFactoryBase` maps `("AGGREGATE", _, 
UnboundedPreceding, UnboundedFollowing, _)` to `UnboundedWindowFunctionFrame` 
regardless of frame type. But both the PR description and the scaladoc describe 
the case as `ROWS ...`, and no test covers `RANGE`. One extra case in the suite 
and a word in the doc would pin it down.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1776,13 +1806,19 @@ object CollapseWindow extends Rule[LogicalPlan] {
     _.containsPattern(WINDOW), ruleId) {
     case w1 @ Window(we1, _, _, w2 @ Window(we2, _, _, grandChild, _), _)
         if windowsCompatible(w1, w2) =>
-      w1.copy(windowExpressions = we2 ++ we1, child = grandChild)
+      w1.copy(
+        orderSpec = if (w1.orderSpec.nonEmpty) w1.orderSpec else w2.orderSpec,

Review Comment:
   **Finding 4.** After this the operator's `orderSpec` is non-empty while the 
merged-in expressions still carry `WindowSpecDefinition(partitionSpec, Nil, 
frame)`. That drops an invariant `ExtractWindowExpressions` sets up -- it keys 
the grouping on the *expression's* own `(partitionSpec, orderSpec, 
functionType)` (`Analyzer.scala:3667`), so until now the operator's spec always 
matched its expressions'. The sibling rule keeps the analogous field in sync 
rather than letting it drift:
   
   ```scala
   // EliminateWindowPartitions.scala:36 -- rewrites the expression's spec, not 
just the operator's
   val newWsd = wsd.copy(partitionSpec = ps.filter(!_.foldable))
   ```
   
   I traced the readers and nothing breaks today: only `WindowResolution` 
(analyzer, already run) and `OptimizeWindowFunctions` (`Optimizer.scala:1745`, 
which needs `orderSpec.nonEmpty` and so stays a no-op on the merged-in 
expression) look at a window expression's own order spec. So this is a "say 
why", not a "fix" -- a line in the rule comment noting the divergence is 
deliberate would help, because it is surprising in `EXPLAIN`. Worth knowing the 
flip side if you ever do sync them: `OptimizeWindowFunctions` would then 
rewrite an empty-order `first` to `nth_value(_, 1)`, which is the same value 
under the merged order.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1761,9 +1761,39 @@ object CollapseWindow extends Rule[LogicalPlan] {
       s1.zip(s2).forall(e => e._1.semanticEquals(e._2))
   }
 
+  /**
+   * Returns true if the given window expression can still be evaluated 
correctly when the rows
+   * of the partition are reordered, so that it can be merged into another 
window with a different
+   * (non-empty) order spec.
+   *
+   * The frame determines whether reordering is safe. When the frame is the 
whole partition
+   * (`UNBOUNDED PRECEDING` to `UNBOUNDED FOLLOWING`), it always covers all 
the rows of the
+   * partition regardless of the ordering, so reordering changes only the 
order in which the rows
+   * are seen, never which rows are in the frame. Since the order spec of the 
window is empty,
+   * the query does not fix the row order, so evaluating its expressions under 
any ordering
+   * yields a valid result, even though the value may differ for 
order-dependent expressions
+   * such as `first`, `collect_list`, or floating-point `sum`/`avg`. On the 
other hand, a bounded
+   * frame (e.g. `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`) is 
order-sensitive: which
+   * rows are in the frame depends on the ordering, so even `count` or `sum` 
would change value,
+   * and such a window must not be merged.
+   */
+  private def canEvaluateUnderAnyOrder(windowExpression: NamedExpression): 
Boolean =

Review Comment:
   **Finding 5.** (Alternative design, not a defect -- take it or leave it.)
   
   The safety argument is much easier to make one layer down. 
`PushDownLocalSort` (`spark.sql.execution.pushDownLocalSort`, default on since 
4.3.0) already widens the lower local sort through an empty-order `WindowExec` 
and drops the upper one; its `isOrderPreserving` has `case _: WindowExecBase => 
true`, with a comment making essentially the argument this scaladoc makes. I 
confirmed on this head that the base plan for the child-empty shape is already 
a single `Sort [c1 ASC, c2 ASC]` feeding both windows, and that base and merged 
results are identical under the default conf.
   
   So a physical rule beside it could merge two adjacent `WindowExec`s whenever 
the lower one's `requiredChildOrdering` is already satisfied by what is 
actually below it. That shape:
   
   - needs no order-insensitivity judgement at all -- the orderings provably 
coincide, so `canEvaluateUnderAnyOrder` and the whole frame check disappear;
   - runs after `InferWindowGroupLimit`, so finding 1 does not arise (a 
`WindowGroupLimitExec` between the two windows simply blocks it);
   - gets the `Project`-between case for free, since `PushDownLocalSort` 
already walks through a deterministic `ProjectExec`.
   
   Counter-argument, and it is a real one: far more work than this two-line 
change -- `windowFrameExpressionFactoryPairs` has to be rebuilt for the merged 
operator and the output attributes rewired, and physical rules are harder to 
follow. I mainly wanted the option on record.
   



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/CollapseWindowSuite.scala:
##########
@@ -168,4 +173,164 @@ class CollapseWindowSuite extends PlanTest {
 
     comparePlans(optimized, correctAnswer)
   }
+
+  test("collapse windows when one has an empty order spec " +

Review Comment:
   **Finding 2.** All six new tests run `Optimize` = `CollapseWindow` + 
`CollapseProject`, so they assert that the merge happens and nothing about what 
the merged shape does to the rules that run after it -- which is where finding 
1 lives. Two gaps:
   
   1. A case that runs `InferWindowGroupLimit` on the merged plan. 
`InferWindowGroupLimitSuite` already has the relation and the rule set to copy:
   
   ```scala
   val batches = Batch("...", FixedPoint(10),
     CollapseWindow, CollapseProject, RemoveNoopOperators, PushDownPredicates,
     InferWindowGroupLimit) :: Nil
   ```
   
   driven by `testRelation.window(Seq(cnt), Seq(c), Nil).window(Seq(rn), 
Seq(c), Seq(a.desc)).where($"rn" <= 2)`, asserting a `WindowGroupLimit` is 
still there.
   
   2. An execution-level result test, e.g. in `DataFrameWindowFunctionsSuite`. 
This rule now changes the row order an aggregate sees, and 
`CollapseWindowSuite` is a `PlanTest`, so nothing in this PR ever runs a merged 
window.
   



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