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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/RemoveRedundantWindowGroupLimits.scala:
##########
@@ -18,20 +18,56 @@
 package org.apache.spark.sql.execution
 
 import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.datasources.v2.GroupPartitionsExec
 import org.apache.spark.sql.execution.window.{Final, Partial, 
WindowGroupLimitExec}
 
 /**
  * Remove redundant partial WindowGroupLimitExec node from the spark plan. A 
partial
- * WindowGroupLimitExec node is redundant when its child satisfies its 
required child distribution.
+ * WindowGroupLimitExec node is redundant when the child left behind by 
removing it satisfies the
+ * final node's required child distribution: the partial node only 
pre-filters, keeping the rows
+ * whose rank within its own partition is within the limit, and a partition of 
the final node is a
+ * union of partitions of the partial node, so it drops no row the final node 
would keep. A
+ * [[GroupPartitionsExec]] between the two nodes, and a local sort, are looked 
through.
  */
 object RemoveRedundantWindowGroupLimits extends Rule[SparkPlan] {
 
   def apply(plan: SparkPlan): SparkPlan = plan transform {
-    case outer @ WindowGroupLimitExec(
-    _, _, _, _, Final, WindowGroupLimitExec(_, _, _, _, Partial, child))
-      if 
child.outputPartitioning.satisfies(outer.requiredChildDistribution.head) =>
-      val newOuter = outer.withNewChildren(Seq(child))
-      newOuter
+    case outer @ WindowGroupLimitExec(_, _, _, _, Final, child) =>
+      val newChild = removePartialLimit(child, hasUpperSort = false)
+      if 
(newChild.outputPartitioning.satisfies(outer.requiredChildDistribution.head)) {
+        outer.withNewChildren(Seq(newChild))
+      } else {
+        outer
+      }
+  }
+
+  /**
+   * Removes the partial [[WindowGroupLimitExec]] at the top of `plan`, 
together with the local sort
+   * feeding it when `hasUpperSort` is set, or returns `plan` unchanged when 
it does not hold one.
+   *
+   * A [[GroupPartitionsExec]] and a local sort are looked through: 
`EnsureRequirements` adds the
+   * grouping between the two nodes when the child needs its partitions 
coalesced or its partition
+   * keys projected to satisfy the final node's distribution, and adds the 
sort between them to give
+   * the final node its ordering. That sort orders the rows the final node 
ranks as a whole, which
+   * is what makes the sort feeding the partial node dead; on its own that one 
is not redundant,
+   * having been added to give the partial node its required ordering.
+   *
+   * @param hasUpperSort whether a local sort between the final node and 
`plan` orders the rows the
+   *                     final node ranks.
+   */
+  private def removePartialLimit(plan: SparkPlan, hasUpperSort: Boolean): 
SparkPlan = plan match {
+    case WindowGroupLimitExec(_, _, _, _, Partial, child) =>
+      if (hasUpperSort) stripLocalSort(child) else child

Review Comment:
   **Finding 1.** `stripLocalSort` returns `child` unchanged when the partial 
node holds no local sort, so in that shape the partial node goes and nothing 
goes with it. The sort above the grouping then has to sort everything the scan 
produces instead of what the partial node let through. That is exactly the 
third case of the new `KeyGroupedPartitioningSuite` test: the source reports 
`(id, name, price)`, which covers the partial node's required ordering, so 
there is no sort under it to remove, and the grouping still forces one above.
   
   Measured on this branch against `b87b39c35699` - one partition key, 
1,000,000 rows over 100 splits of 10,000, `rk = 1`, source reporting the 
window's ordering:
   
   | | rows into the surviving sort | `sortTime` | wall (steady) |
   |---|---|---|---|
   | `b87b39c35699` | 200, the partial node's output | 0 ms | 372-415 ms |
   | `0066cc2b3a1` | 1,000,000 | 11-13 ms | 400-427 ms |
   
   Wall clock only moves 4-7% here because the `InMemoryTable` scan dominates 
and 1M narrow rows fit in memory - `spillSize` is 0 in both arms. On a real 
source the same plan change is a 200-row sort against a spilling one.
   
   `PushDownLocalSort`, in this package, declines this trade by default. Its 
`isOrderPreserving` lists `WindowGroupLimitExec` as a cardinality reducer and 
only crosses it behind 
`spark.sql.execution.pushDownLocalSort.throughCardinalityReducer`, whose doc 
says "moving the wider sort below a selective reducer can sort the full input 
instead of only the surviving rows, which may outweigh the saved sort". That is 
the same sentence as this shape.
   
   The other two shapes are fine, so the removal is worth doing exactly when 
something goes with it. Where a sort does go away with the partial node the 
measurement came out slightly ahead (one 100k sort at 3 ms against ten 10k 
sorts at 5 ms), and where no sort sits above the grouping the sort that stays 
is unaffected.
   
   ```suggestion
         if (hasUpperSort) {
           // With no sort to remove, the partial node is the only cardinality 
reducer between its
           // child and the sort above, and dropping it leaves that sort with 
the whole input -
           // `PushDownLocalSort.isOrderPreserving` declines the same trade by 
default.
           child match {
             case sort: SortExec if !sort.global => sort.child
             case _ => plan
           }
         } else {
           child
         }
   ```
   
   I ran that: `byKey` and `byPrice` still pass and the `reported` case keeps 
both nodes, so the third block of the new test would want to assert that 
instead. `stripLocalSort` then has no other caller, and the `hasUpperSort` 
scaladoc needs a word about the sort being the condition rather than a bonus.
   
   One more thing this shape changes, which argues the same way: while the 
partial node sat under the grouping, 
`GroupPartitionsExec.childIsSafeForKWayMerge` was false (`WindowGroupLimitExec` 
is not a `SafeForKWayMerge`), so `EnsureRequirements.tryEnableSortedMerge` 
could never enable the merge and always fell through to the `SortExec` at 
`EnsureRequirements.scala:327`. After the removal the grouping's child is the 
scan and the merge becomes feasible, but `enableSortedMerge` was already 
decided and this rule runs afterwards, so nothing reconsiders it. I tried 
reclaiming it (`newGroup.tryEnableSortedMerge().getOrElse(newGroup)` here) and 
it does produce a sort-free plan - `GroupPartitions ... SortedMerge: true` with 
`numSorts=0`, `RemoveRedundantSorts` dropping the sort - but it measured 
606-608 ms against the 372-415 ms of master, so a 100-way merge over 1M rows is 
no bargain either. Keeping the reducer is the cheap answer in this shape.
   
   Finally, the description reads as a pure win ("drops an operator and a sort 
pass from the plan"). Worth saying what the surviving sort now sees.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/RemoveRedundantWindowGroupLimitsSuite.scala:
##########
@@ -69,6 +69,36 @@ abstract class RemoveRedundantWindowGroupLimitsSuiteBase
       checkWindowGroupLimits(query2, 2)
     }
   }
+
+  test("SPARK-59492: the local sort of a removed WindowGroupLimit stays where 
it was") {
+    withTempView("t") {
+      spark.range(0, 100).withColumn("value", 
lit(1)).createOrReplaceTempView("t")
+      // The child is already clustered on the window's PARTITION BY, so no 
exchange lands between
+      // the two limit nodes and nothing above them orders the rows the final 
node ranks: the local
+      // sort that fed the partial node is the one carrying that ordering, and 
it stays where the
+      // partial node was.
+      val df = sql(
+        """
+          |SELECT *
+          |FROM (
+          |    SELECT id, rank() OVER w AS rn
+          |    FROM t
+          |    GROUP BY id
+          |    WINDOW w AS (PARTITION BY id ORDER BY max(value))
+          |)
+          |WHERE rn < 3
+          |""".stripMargin)
+      val plan = df.queryExecution.executedPlan
+      val limits = collectWithSubqueries(plan) { case w: WindowGroupLimitExec 
=> w.mode }
+      assert(limits == Seq(Final), s"expected the final limit alone, got 
$limits:\n$plan")
+      val sorts = collectWithSubqueries(plan) { case s: SortExec => s 
}.filter(!_.global)
+      assert(sorts.length == 1, s"expected one local sort, got 
${sorts.length}:\n$plan")
+      assert(!sorts.head.child.isInstanceOf[WindowGroupLimitExec],

Review Comment:
   **Finding 3.** In this plan the partial node sits *above* the sort, never 
below it, so `sorts.head.child` is the `HashAggregate` whether or not the rule 
fires - on master too. The assertion therefore holds on every outcome and pins 
nothing; `sorts.length == 1` on the line above is what carries the test. I 
would drop this line, or, if the intent is that the sort ended up where the 
partial node was, assert on the final node's child after unwrapping the codegen 
wrapper (`SortExec` supports codegen here, so a bare `isInstanceOf` on it would 
not hold).
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/RemoveRedundantWindowGroupLimitsSuite.scala:
##########
@@ -69,6 +69,36 @@ abstract class RemoveRedundantWindowGroupLimitsSuiteBase
       checkWindowGroupLimits(query2, 2)
     }
   }
+
+  test("SPARK-59492: the local sort of a removed WindowGroupLimit stays where 
it was") {

Review Comment:
   **Finding 2.** This test passes on `b87b39c35699` in both AQE modes - I ran 
it there. The query is `query1` from `remove redundant WindowGroupLimits` 
verbatim, and on master the old rule already rewrote 
`Final(Partial(Sort(...)))` into `Final(Sort(...))`, so every assertion here 
holds without the patch.
   
   It is not worthless, since it would catch `hasUpperSort` defaulting to 
`true`. But as a separate test it re-runs an existing query and builds the same 
plan twice. Folding the two sort assertions into `remove redundant 
WindowGroupLimits`, right next to `checkWindowGroupLimits(query1, 1)`, pins the 
same property without the duplicate.
   



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