This is an automated email from the ASF dual-hosted git repository.

uros-b pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 6567619c0205 [SPARK-58233][SQL] Push down local sort to reduce 
redundant sorts within a stage
6567619c0205 is described below

commit 6567619c0205f16d94633840eca5454fac70c9a0
Author: Xiduo You <[email protected]>
AuthorDate: Wed Jul 22 15:43:04 2026 +0000

    [SPARK-58233][SQL] Push down local sort to reduce redundant sorts within a 
stage
    
    ### What changes were proposed in this pull request?
    
    Adds a new physical rule `PushDownLocalSort`, wired into both the AQE 
`queryStagePreparationRules` and the non-AQE `QueryExecution` preparations 
(right after `EnsureRequirements`, before `CombineAdjacentAggregation`), gated 
by a new internal config `spark.sql.execution.pushDownLocalSort` (default 
`true`).
    
    `EnsureRequirements` inserts one local `SortExec(global = false)` above 
every operator whose `requiredChildOrdering` is not satisfied. When two such 
requirements are in a prefix-cover relationship, a stage computes multiple 
local sorts that only differ in width. The rule pushes the wider local sort 
down through order-preserving operators onto the narrower one below, widening 
it so a single sort serves both operators and the redundant upper sort is 
dropped.
    
    For example, a sort aggregate stacked on a window over the same clustering 
keys:
    
    ```
    SortAggregate(key = [a, b, c])
      Sort([a, b, c], global = false)   <- upper, wider
        Window([a], [b])
          Sort([a, b], global = false)  <- lower, narrower
            Exchange(hashpartitioning([a]))
    ```
    
    becomes:
    
    ```
    SortAggregate(key = [a, b, c])
      Window([a], [b])                  requiredChildOrdering [a, b] satisfied 
by [a, b, c]
        Sort([a, b, c], global = false) <- single sort now serves both operators
          Exchange(hashpartitioning([a]))
    ```
    
    Order-preserving operators traversed: `ProjectExec`, `WindowExecBase`, and 
— only when `spark.sql.execution.pushDownLocalSort.throughCardinalityReducer` 
is enabled (default `false`) — the cardinality reducers `FilterExec` and 
`WindowGroupLimitExec`. Crossing a reducer can move the wider sort from the 
surviving rows to the full input, which may outweigh the saved sort, so it is 
opt-in. A `ProjectExec` or `FilterExec` is crossed only when its expressions 
are deterministic (mirroring `El [...]
    
    `CollectMetricsExec` is intentionally excluded: it observes rows in input 
order, so widening a sort under it would silently change order-sensitive 
observed metrics (`first`/`last`/`collect_list`).
    
    This is complementary to `RemoveRedundantSorts`, which only removes a sort 
already satisfied by its child; it never moves a wider sort down to absorb a 
narrower one.
    
    ### Why are the changes needed?
    
    Stacked windows, or a sort aggregate over a window with prefix-compatible 
ordering requirements, compute several local sorts that a single wider sort 
could satisfy. Reusing the widest sort saves the redundant sorting work in the 
stage.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. It only removes redundant local sorts from physical plans; the result 
of a deterministic query is unchanged. As with any plan change that alters 
within-partition row order, an order-sensitive window function over a 
non-unique `ORDER BY` (e.g. `first_value`/`last_value`/`collect_list`) may 
return a different — but still valid — non-deterministic result. Guarded by 
`spark.sql.execution.pushDownLocalSort`.
    
    ### How was this patch tested?
    
    New `PushDownLocalSortSuite` (AQE on/off variants) with:
    - positive SQL cases: stacked windows, filter above the sorts, window to 
sort aggregate, renaming project;
    - negative SQL cases: disjoint orderings, a shuffle between the sorts, 
sort-direction mismatch, a project that computes the ordering column;
    - plan-level cases: multi-operator chain, renaming-project rewrite, three 
stacked sorts in one pass, non-deterministic project/filter are not crossed, 
and a cardinality-reducing filter is crossed only when the reducer config is 
enabled.
    
    Verified the TPCDS/TPCH `PlanStability` golden plans are unchanged.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #57396 from ulysses-you/spark-pushdown-merge-local-sort.
    
    Authored-by: Xiduo You <[email protected]>
    Signed-off-by: Uros Bojanic <[email protected]>
    (cherry picked from commit 589adcb9b0b4ee247d37acadd851baac776a5a91)
    Signed-off-by: Uros Bojanic <[email protected]>
---
 .../org/apache/spark/sql/internal/SQLConf.scala    |  26 ++
 .../spark/sql/execution/PushDownLocalSort.scala    | 156 ++++++++
 .../spark/sql/execution/QueryExecution.scala       |   4 +
 .../execution/adaptive/AdaptiveSparkPlanExec.scala |   4 +
 .../sql/execution/PushDownLocalSortSuite.scala     | 401 +++++++++++++++++++++
 5 files changed, 591 insertions(+)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 5cb317a52b33..ea4c2ffc1559 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -3001,6 +3001,32 @@ object SQLConf {
     .booleanConf
     .createWithDefault(true)
 
+  val PUSH_DOWN_LOCAL_SORT_ENABLED =
+    buildConf("spark.sql.execution.pushDownLocalSort")
+      .internal()
+      .doc("When true, pushes a wider local sort down through order-preserving 
" +
+        "operators to replace a narrower local sort below it, so that a single 
sort can satisfy " +
+        "multiple operators' ordering requirements. This reduces the total 
number of local sorts " +
+        "computed in a stage, for example when a sort aggregate is stacked on 
a window over the " +
+        "same clustering keys.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(true)
+
+  val PUSH_DOWN_LOCAL_SORT_THROUGH_CARDINALITY_REDUCER_ENABLED =
+    
buildConf("spark.sql.execution.pushDownLocalSort.throughCardinalityReducer")
+      .internal()
+      .doc("When true, `spark.sql.execution.pushDownLocalSort` may also push a 
wider local sort " +
+        "down through cardinality-reducing operators (`FilterExec` and 
`WindowGroupLimitExec`). " +
+        "This is disabled by default because 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. Has no effect when `spark.sql.execution.pushDownLocalSort` is 
false.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(false)
+
   val REPLACE_HASH_WITH_SORT_AGG_ENABLED = 
buildConf("spark.sql.execution.replaceHashWithSortAgg")
     .internal()
     .doc("Whether to replace hash aggregate node with sort aggregate based on 
children's ordering")
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/PushDownLocalSort.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/PushDownLocalSort.scala
new file mode 100644
index 000000000000..161e779a0c9a
--- /dev/null
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/PushDownLocalSort.scala
@@ -0,0 +1,156 @@
+/*
+ * 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
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, 
AttributeMap, AttributeReference, AttributeSet, SortOrder}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.window.{WindowExecBase, 
WindowGroupLimitExec}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Pushes a wider local sort down through order-preserving operators onto a 
narrower local sort
+ * below, widening it so that a single sort satisfies several operators' 
ordering requirements
+ * instead of re-sorting once per operator.
+ *
+ * `EnsureRequirements` adds one local `SortExec` (`global = false`) above 
every operator whose
+ * `requiredChildOrdering` is not already satisfied. When such requirements 
are in a prefix-cover
+ * relationship, this produces multiple local sorts that only differ in width. 
A canonical case is a
+ * sort aggregate stacked on a window over the same clustering keys, where the 
aggregate needs a
+ * wider ordering than the window:
+ *
+ * {{{
+ *   SortAggregate(key = [a, b, c])
+ *     Sort([a, b, c], global = false)   <- upper, wider
+ *       Window([a], [b])
+ *         Sort([a, b], global = false)  <- lower, narrower
+ *           Exchange(hashpartitioning([a]))
+ * }}}
+ *
+ * Because every operator between the two sorts is order-preserving and the 
upper ordering
+ * prefix-covers everything required along the way, the wider ordering can be 
pushed down to widen
+ * the lower sort, and the upper sort then dropped entirely:
+ *
+ * {{{
+ *   SortAggregate(key = [a, b, c])
+ *     Window([a], [b])                  requiredChildOrdering [a, b] is 
satisfied by [a, b, c]
+ *       Sort([a, b, c], global = false) <- single sort now serves both 
operators
+ *         Exchange(hashpartitioning([a]))
+ * }}}
+ *
+ * When a `ProjectExec` on the path renames an ordering column in its output 
(`b AS x`), the
+ * ordering is rewritten from the project's output space back to its child's 
space (`x` -> `b`) as
+ * it is pushed through, so a sort over the renamed column is still matched 
below. Only plain
+ * renames are followed, and the rule never crosses a shuffle or a 
non-order-preserving operator.
+ */
+object PushDownLocalSort extends Rule[SparkPlan] {
+
+  def apply(plan: SparkPlan): SparkPlan = {
+    if (!conf.getConf(SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED)) {
+      return plan
+    }
+    val throughCardinalityReducer =
+      
conf.getConf(SQLConf.PUSH_DOWN_LOCAL_SORT_THROUGH_CARDINALITY_REDUCER_ENABLED)
+
+    plan.transform {
+      case upper @ SortExec(upperOrder, false, child, _) =>
+        pushDown(child, upperOrder, throughCardinalityReducer).getOrElse(upper)
+    }
+  }
+
+  /**
+   * Walks down from `plan` through a chain of order-preserving unary 
operators, looking for a
+   * lower local `SortExec` that `upperOrder` strictly covers. When found, 
widens that lower sort
+   * to `upperOrder` and returns the rebuilt subtree (which re-exposes 
`upperOrder` at its top);
+   * returns `None` if no safe widening applies, leaving the plan untouched. 
As it crosses an
+   * operator that renames ordering columns, `upperOrder` is rewritten into 
that operator's child
+   * space so the search continues against the child's own attributes. 
`throughCardinalityReducer`
+   * controls whether cardinality-reducing operators (`FilterExec`, 
`WindowGroupLimitExec`) may be
+   * crossed.
+   */
+  private def pushDown(
+      plan: SparkPlan,
+      upperOrder: Seq[SortOrder],
+      throughCardinalityReducer: Boolean): Option[SparkPlan] = plan match {
+    case lower @ SortExec(lowerOrder, false, _, _)
+        // Only widen when the upper ordering strictly covers the lower one. 
When they are
+        // equivalent the upper sort is plainly redundant and is left to 
`RemoveRedundantSorts`; a
+        // non-covering ordering cannot serve the lower requirement. The 
column check keeps the
+        // widened sort well-formed (every key of `upperOrder` is available 
below the lower sort).
+        if SortOrder.orderingSatisfies(upperOrder, lowerOrder) &&
+          !SortOrder.orderingSatisfies(lowerOrder, upperOrder) &&
+          
AttributeSet(upperOrder.flatMap(_.references)).subsetOf(lower.child.outputSet) 
=>
+      Some(SortExec(upperOrder, global = false, child = lower.child))
+
+    case op: UnaryExecNode if isOrderPreserving(op, throughCardinalityReducer) 
=>
+      // A `ProjectExec` may rename ordering columns in its output (`b AS x`). 
Rewrite `upperOrder`
+      // from the operator's output space back to its child's space before 
pushing further down, so
+      // a sort over the renamed column is still matched below. Only plain 
renames are followed; an
+      // expression alias leaves the sort key referencing an output attribute 
the child does not
+      // produce, so the `subsetOf(op.child.outputSet)` check below rejects it.
+      val outputExprs = plan match {
+        case p: ProjectExec => p.projectList
+        case _ => Nil
+      }
+      val rewrittenUpperOrder = if (outputExprs.isEmpty) {
+        upperOrder
+      } else {
+        val aliasToAttributeMap = AttributeMap(outputExprs.collect {
+          case a @ Alias(child: AttributeReference, _) => (a.toAttribute, 
child: Attribute)
+        })
+        upperOrder.map { _.transformUp {
+            case a: Attribute => aliasToAttributeMap.getOrElse(a, a)
+          }.asInstanceOf[SortOrder]
+        }
+      }
+      if (SortOrder.orderingSatisfies(rewrittenUpperOrder, 
op.requiredChildOrdering.head) &&
+          
AttributeSet(rewrittenUpperOrder.flatMap(_.references)).subsetOf(op.child.outputSet))
 {
+        pushDown(op.child, rewrittenUpperOrder, throughCardinalityReducer)
+          .map(newChild => op.withNewChildren(Seq(newChild)))
+      } else {
+        None
+      }
+
+    case _ => None
+  }
+
+  private def isOrderPreserving(
+      plan: UnaryExecNode,
+      throughCardinalityReducer: Boolean): Boolean = plan match {
+    // A non-deterministic project/filter must not be crossed: moving the sort 
below it changes
+    // which rows a seeded non-deterministic expression is evaluated over (a 
different row-value
+    // association for a project, a different surviving set for a filter). 
This mirrors the
+    // determinism guard in the logical `EliminateSorts.canEliminateSort`.
+    case p: ProjectExec => p.projectList.forall(_.deterministic)
+    // `FilterExec` and `WindowGroupLimitExec` are cardinality reducers: 
pushing the wider sort
+    // below them sorts the full input instead of only the surviving rows, 
which can outweigh the
+    // saved sort. Only cross them when explicitly enabled. `FilterExec` 
additionally needs the
+    // determinism guard, for the same reason as the project above.
+    case f: FilterExec => throughCardinalityReducer && 
f.condition.deterministic
+    case _: WindowGroupLimitExec => throughCardinalityReducer
+    // Pushing a wider sort under a window refines the input order the window 
sees within ties of
+    // its own `ORDER BY`. For order-sensitive window functions 
(`first_value`/`last_value`/
+    // `collect_list`, `lead`/`lag`, `ROWS`-frame aggregates) that can change 
the result -- but only
+    // for a non-unique window `ORDER BY`, where the result is already 
non-deterministic by Spark's
+    // contract, so this does not change any deterministic result. This 
differs from
+    // `CollectMetricsExec`, which is deliberately excluded: its observed 
metric is a documented,
+    // expected-stable side output, not a non-deterministic query result, so 
refining the order it
+    // sees would be a real surprise.
+    case _: WindowExecBase => true
+    case _ => false
+  }
+}
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
index f70eafc70af3..0a1185aa4ea5 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
@@ -764,6 +764,10 @@ object QueryExecution {
       EnsureRequirements(),
       // This rule must be run after `EnsureRequirements`.
       InsertSortForLimitAndOffset,
+      // `PushDownLocalSort` pushes a wider local sort down onto a narrower 
one below it, so a
+      // single sort serves several operators' ordering requirements. It must 
run after
+      // `EnsureRequirements`, which is what inserts the local sorts it pushes 
down.
+      PushDownLocalSort,
       // `CombineAdjacentAggregation` must run before 
`ReplaceHashWithSortAgg`: it combines a pair
       // of adjacent partial and final aggregate into a single `Complete` mode 
aggregate, which
       // `ReplaceHashWithSortAgg` can then replace with a sort aggregate when 
the ordering allows.
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
index 1905b31b49f9..bfe6a9a3f633 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
@@ -131,6 +131,10 @@ case class AdaptiveSparkPlanExec(
       // join drops its child ordering, which `ReplaceHashWithSortAgg` would 
otherwise rely on to
       // turn a hash aggregate into a sort aggregate.
       ConvertSortMergeJoinToShuffledHashJoin(ensureRequirements),
+      // `PushDownLocalSort` pushes a wider local sort down onto a narrower 
one below it, so a
+      // single sort serves several operators' ordering requirements. It must 
run after
+      // `EnsureRequirements`, which is what inserts the local sorts it pushes 
down.
+      PushDownLocalSort,
       // `CombineAdjacentAggregation` must run before 
`ReplaceHashWithSortAgg`: it combines a pair
       // of adjacent partial and final aggregate into a single `Complete` mode 
aggregate, which
       // `ReplaceHashWithSortAgg` can then replace with a sort aggregate when 
the ordering allows.
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/PushDownLocalSortSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/PushDownLocalSortSuite.scala
new file mode 100644
index 000000000000..42ea16048d66
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/PushDownLocalSortSuite.scala
@@ -0,0 +1,401 @@
+/*
+ * 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
+
+import org.apache.spark.sql.DataFrame
+import org.apache.spark.sql.catalyst.expressions.{Alias, Ascending, 
AttributeReference, IsNotNull, LessThan, Literal, Rand, SortOrder}
+import org.apache.spark.sql.catalyst.plans.physical.UnspecifiedDistribution
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanHelper, 
DisableAdaptiveExecutionSuite, EnableAdaptiveExecutionSuite}
+import org.apache.spark.sql.execution.exchange.ValidateRequirements
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.IntegerType
+
+
+abstract class PushDownLocalSortSuiteBase
+    extends SharedSparkSession
+    with AdaptiveSparkPlanHelper {
+
+  private def checkNumSorts(df: DataFrame, count: Int): Unit = {
+    // Execute first so that, under AQE, the final adaptive plan (after 
query-stage materialization
+    // and any replanning) is inspected rather than the initial plan.
+    df.collect()
+    val plan = df.queryExecution.executedPlan
+    assert(collectWithSubqueries(plan) { case s: SortExec => s }.length == 
count)
+  }
+
+  private def checkSorts(query: String, enabledCount: Int, disabledCount: 
Int): Unit = {
+    withSQLConf(SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "true") {
+      val df = sql(query)
+      checkNumSorts(df, enabledCount)
+      val result = df.collect()
+      withSQLConf(SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "false") {
+        val df = sql(query)
+        checkNumSorts(df, disabledCount)
+        checkAnswer(df, result)
+      }
+    }
+  }
+
+  test("Push a wider local sort down across stacked windows with 
prefix-compatible order specs") {
+    withTempView("t") {
+      spark.range(100).selectExpr("id % 10 as a", "id % 7 as b", "id as c")
+        .createOrReplaceTempView("t")
+      // The narrower window (order by b) is listed first, so it is planned 
closest to the leaf and
+      // the wider window (order by b, c) ends up above it. Without the rule 
two local sorts
+      // ([a, b] below the inner window, [a, b, c] above it) are computed. The 
rule widens the
+      // lower sort to [a, b, c] so it serves both windows and drops the upper 
sort, leaving a
+      // single [a, b, c] sort.
+      val query =
+        """
+          |SELECT a, b, c,
+          |  RANK() OVER (PARTITION BY a ORDER BY b) AS rk,
+          |  ROW_NUMBER() OVER (PARTITION BY a ORDER BY b, c) AS rn
+          |FROM t
+          |""".stripMargin
+      checkSorts(query, 1, 2)
+    }
+  }
+
+  test("No-op when the wider sort is already below the narrower one") {
+    withTempView("t") {
+      spark.range(100).selectExpr("id % 10 as a", "id % 7 as b", "id as c")
+        .createOrReplaceTempView("t")
+      // The wider window (order by b, c) is listed first, so it is planned 
closest to the leaf and
+      // the narrower window (order by b) ends up above it. 
`EnsureRequirements` inserts only one
+      // sort here: the wider window's [a, b, c] sort already satisfies the 
narrower window's
+      // [a, b] requirement, so no second sort is added. This rule only pushes 
a wider sort down,
+      // so it does not fire; the single sort is unchanged whether it is on or 
off.
+      val query =
+        """
+          |SELECT a, b, c,
+          |  ROW_NUMBER() OVER (PARTITION BY a ORDER BY b, c) AS rn,
+          |  RANK() OVER (PARTITION BY a ORDER BY b) AS rk
+          |FROM t
+          |""".stripMargin
+      checkSorts(query, 1, 1)
+    }
+  }
+
+  test("Push-down still applies and stays correct with a filter on a window 
output") {
+    withTempView("t") {
+      spark.range(200).selectExpr("id % 10 as a", "id % 7 as b", "id as c")
+        .createOrReplaceTempView("t")
+      val query =
+        """
+          |SELECT * FROM (
+          |  SELECT a, b, c,
+          |    RANK() OVER (PARTITION BY a ORDER BY b) AS rk,
+          |    ROW_NUMBER() OVER (PARTITION BY a ORDER BY b, c) AS rn
+          |  FROM t
+          |) WHERE rn > 1
+          |""".stripMargin
+      // The filter on rn sits above both windows and does not affect the two 
sorts that feed them,
+      // so the push-down still reduces 2 sorts to 1 and the results are 
unchanged.
+      checkSorts(query, 1, 2)
+    }
+  }
+
+  test("Push a wider sort down through a window to feed a sort aggregate above 
it") {
+    withTempView("t") {
+      // `c` varies within each (a, b) group (70 % 13 != 0) and `b` repeats 
within each partition
+      // `a`, so widening the window's sort from [a, b] to [a, b, c] genuinely 
reorders rows within
+      // the window's `ORDER BY b` ties. `RANK()` is tie-safe -- its value 
does not depend on the
+      // order of tied rows -- so the result stays stable while the sort count 
still drops.
+      spark.range(500).selectExpr("id % 10 as a", "id % 7 as b", "id % 13 as 
c")
+        .createOrReplaceTempView("t")
+      // Plan shape within one stage: shuffle -> Sort([a,b,c]) -> 
Window([a],[b]) -> Sort([a,b,c])
+      // -> SortAggregate(group by a,b,c). The window needs [a,b] and the sort 
aggregate needs the
+      // wider [a,b,c]; the aggregate's grouping keys are clustered-compatible 
with the window's
+      // partitioning, so no shuffle separates them. The wider [a,b,c] sort 
feeding the aggregate is
+      // pushed down through the window, replacing the window's [a,b] sort and 
serving both.
+      // `collect_list` with object-hash aggregation off forces a sort 
aggregate.
+      withSQLConf(SQLConf.USE_OBJECT_HASH_AGG.key -> "false") {
+        val query =
+          """
+            |SELECT a, b, c, collect_list(rk) AS cl
+            |FROM (SELECT a, b, c, RANK() OVER (PARTITION BY a ORDER BY b) AS 
rk FROM t)
+            |GROUP BY a, b, c
+            |""".stripMargin
+        checkSorts(query, 1, 2)
+      }
+    }
+  }
+
+  test("Push a wider sort down through a renaming project by rewriting the 
ordering") {
+    withTempView("t") {
+      spark.range(200).selectExpr("id % 10 as a", "id % 7 as b", "id % 5 as c")
+        .createOrReplaceTempView("t")
+      // The inner window sorts by [a, b]; a project then renames b to bb; the 
outer window sorts by
+      // [a, bb, c] (wider). The wider sort's key `bb` is the renamed `b`, so 
pushing it down
+      // through the renaming project requires rewriting the ordering from bb 
back to b. After the
+      // rewrite the [a, b, c] sort is pushed through the inner window, 
replacing its [a, b] sort.
+      val query =
+        """
+          |SELECT a, bb, c, rn1,
+          |  ROW_NUMBER() OVER (PARTITION BY a ORDER BY bb, c) AS rn2
+          |FROM (
+          |  SELECT a, b AS bb, c, rn1 FROM (
+          |    SELECT a, b, c, RANK() OVER (PARTITION BY a ORDER BY b) AS rn1 
FROM t
+          |  )
+          |)
+          |""".stripMargin
+      checkSorts(query, 1, 2)
+    }
+  }
+
+  test("Negative: no push-down when the two orderings are disjoint") {
+    withTempView("t") {
+      spark.range(300).selectExpr("id % 10 as a", "id % 7 as b", "id % 5 as c")
+        .createOrReplaceTempView("t")
+      // Two windows over the same partition but disjoint order columns (b vs 
c). Neither ordering
+      // covers the other, so neither local sort can be pushed onto the other: 
both survive.
+      val query =
+        """
+          |SELECT a, b, c,
+          |  RANK() OVER (PARTITION BY a ORDER BY b) AS rk,
+          |  RANK() OVER (PARTITION BY a ORDER BY c) AS rc
+          |FROM t
+          |""".stripMargin
+      checkSorts(query, 2, 2)
+    }
+  }
+
+  test("Negative: no push-down when a shuffle separates the two sorts") {
+    withTempView("t") {
+      spark.range(300).selectExpr("id % 10 as a", "id % 7 as b", "id % 5 as c")
+        .createOrReplaceTempView("t")
+      // Two windows partitioned by different keys (a vs b) force a shuffle 
between their local
+      // sorts. A local sort is never pushed across a shuffle, so both sorts 
survive.
+      val query =
+        """
+          |SELECT a, b,
+          |  RANK() OVER (PARTITION BY a ORDER BY b) AS rk,
+          |  RANK() OVER (PARTITION BY b ORDER BY a) AS rb
+          |FROM t
+          |""".stripMargin
+      checkSorts(query, 2, 2)
+    }
+  }
+
+  test("Negative: no push-down when the sort directions differ") {
+    withTempView("t") {
+      spark.range(300).selectExpr("id % 10 as a", "id % 7 as b", "id % 5 as c")
+        .createOrReplaceTempView("t")
+      // The inner window orders by b ascending; the outer orders by b 
descending then c. The outer
+      // ordering [a, b DESC, c] does not cover the inner [a, b ASC] because 
the directions differ,
+      // so the wider sort cannot be pushed down and both sorts survive.
+      val query =
+        """
+          |SELECT a, b, c,
+          |  RANK() OVER (PARTITION BY a ORDER BY b) AS rk,
+          |  ROW_NUMBER() OVER (PARTITION BY a ORDER BY b DESC, c) AS rn
+          |FROM t
+          |""".stripMargin
+      checkSorts(query, 2, 2)
+    }
+  }
+
+  test("Negative: no push-down when an intermediate project computes the 
ordering column") {
+    withTempView("t") {
+      spark.range(300).selectExpr("id % 10 as a", "id % 7 as b", "id % 5 as c")
+        .createOrReplaceTempView("t")
+      // Between the two window sorts a project derives `bx = b + 1` (an 
expression alias, not a
+      // plain rename). The outer window orders by [a, bx, c]; `bx` is only an 
expression alias, so
+      // it is not rewritten back to `b` and is absent from the project's 
input. The push-down is
+      // rejected because the rewritten ordering still references a column the 
child does not
+      // produce, and both sorts survive.
+      val query =
+        """
+          |SELECT a, bx, c, rn1,
+          |  ROW_NUMBER() OVER (PARTITION BY a ORDER BY bx, c) AS rn2
+          |FROM (
+          |  SELECT a, b + 1 AS bx, c, rn1 FROM (
+          |    SELECT a, b, c, RANK() OVER (PARTITION BY a ORDER BY b) AS rn1 
FROM t
+          |  )
+          |)
+          |""".stripMargin
+      checkSorts(query, 2, 2)
+    }
+  }
+
+  test("Negative: do not push through a non-deterministic project") {
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    val scan = LocalTableScanExec(Seq(a, b), Nil, None)
+    val orderA = SortOrder(a, Ascending) :: Nil
+    val orderAB = SortOrder(a, Ascending) :: SortOrder(b, Ascending) :: Nil
+
+    // SortExec([a, b]) <- Project[a, b, rand(0)] <- SortExec([a]). Pushing 
the sort below the
+    // project would change which rows the seeded random stream is evaluated 
over, so the rule must
+    // not cross a non-deterministic project (mirrors 
`EliminateSorts.canEliminateSort`).
+    val lower = SortExec(orderA, global = false, scan)
+    val project = ProjectExec(Seq(a, b, Alias(Rand(Literal(0L)), "r")()), 
lower)
+    val upper = SortExec(orderAB, global = false, project)
+
+    withSQLConf(SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "true") {
+      assert(PushDownLocalSort(upper).fastEquals(upper), "the plan must be 
left unchanged")
+    }
+  }
+
+  test("Negative: do not push through a non-deterministic filter") {
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    val scan = LocalTableScanExec(Seq(a, b), Nil, None)
+    val orderA = SortOrder(a, Ascending) :: Nil
+    val orderAB = SortOrder(a, Ascending) :: SortOrder(b, Ascending) :: Nil
+
+    // SortExec([a, b]) <- Filter(rand(0) < 0.5) <- SortExec([a]). Even with 
cardinality-reducer
+    // traversal enabled, a non-deterministic filter must not be crossed: the 
surviving row set
+    // would change if the sort moved below it.
+    val lower = SortExec(orderA, global = false, scan)
+    val filter = FilterExec(LessThan(Rand(Literal(0L)), Literal(0.5)), lower)
+    val upper = SortExec(orderAB, global = false, filter)
+
+    withSQLConf(
+        SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "true",
+        SQLConf.PUSH_DOWN_LOCAL_SORT_THROUGH_CARDINALITY_REDUCER_ENABLED.key 
-> "true") {
+      assert(PushDownLocalSort(upper).fastEquals(upper), "the plan must be 
left unchanged")
+    }
+  }
+
+  test("Cardinality reducer: do not cross a filter unless explicitly enabled") 
{
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    val scan = LocalTableScanExec(Seq(a, b), Nil, None)
+    val orderA = SortOrder(a, Ascending) :: Nil
+    val orderAB = SortOrder(a, Ascending) :: SortOrder(b, Ascending) :: Nil
+
+    // SortExec([a, b]) <- Filter(a IS NOT NULL) <- SortExec([a]). A filter is 
a cardinality
+    // reducer, so it is only crossed when the reducer config is on.
+    val lower = SortExec(orderA, global = false, scan)
+    val filter = FilterExec(IsNotNull(a), lower)
+    val upper = SortExec(orderAB, global = false, filter)
+
+    withSQLConf(SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "true") {
+      // Reducer traversal off (default): the filter is not crossed, both 
sorts survive.
+      withSQLConf(
+          SQLConf.PUSH_DOWN_LOCAL_SORT_THROUGH_CARDINALITY_REDUCER_ENABLED.key 
-> "false") {
+        assert(PushDownLocalSort(upper).fastEquals(upper), "must not cross the 
filter by default")
+      }
+      // Reducer traversal on: the sort is pushed below the filter and the two 
sorts become one.
+      withSQLConf(
+          SQLConf.PUSH_DOWN_LOCAL_SORT_THROUGH_CARDINALITY_REDUCER_ENABLED.key 
-> "true") {
+        val rewritten = PushDownLocalSort(upper)
+        assert(rewritten.collect { case s: SortExec => s }.length == 1)
+      }
+    }
+  }
+
+  test("Plan-level: push a wider sort down through order-preserving 
operators") {
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    val scan = LocalTableScanExec(Seq(a, b), Nil, None)
+    val orderA = SortOrder(a, Ascending) :: Nil
+    val orderAB = SortOrder(a, Ascending) :: SortOrder(b, Ascending) :: Nil
+
+    // SortExec([a, b]) <- Filter <- Project <- SortExec([a]) : the wider sort 
above is pushed down
+    // to widen the lower [a] sort into [a, b], and the upper sort is dropped, 
leaving one sort.
+    // Crossing the `Filter` requires the cardinality-reducer config.
+    val lower = SortExec(orderA, global = false, scan)
+    val project = ProjectExec(Seq(a, b), lower)
+    val filter = FilterExec(IsNotNull(a), project)
+    val upper = SortExec(orderAB, global = false, filter)
+
+    withSQLConf(
+        SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "true",
+        SQLConf.PUSH_DOWN_LOCAL_SORT_THROUGH_CARDINALITY_REDUCER_ENABLED.key 
-> "true") {
+      val rewritten = PushDownLocalSort(upper)
+      val sorts = rewritten.collect { case s: SortExec => s }
+      assert(sorts.length == 1, "the two sorts become one")
+      // The single remaining sort is the wide [a, b] sort, sitting at the 
bottom of the chain.
+      assert(sorts.head.sortOrder == orderAB)
+      assert(sorts.head.child.isInstanceOf[LocalTableScanExec])
+      // The rewritten plan still re-exposes the [a, b] ordering that the 
dropped upper sort gave.
+      assert(SortOrder.orderingSatisfies(rewritten.outputOrdering, orderAB))
+      // Every operator's required ordering is still satisfied.
+      assert(ValidateRequirements.validate(rewritten, UnspecifiedDistribution))
+    }
+  }
+
+  test("Plan-level: rewrite the ordering through a renaming project when 
pushing down") {
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    val scan = LocalTableScanExec(Seq(a, b), Nil, None)
+    val orderA = SortOrder(a, Ascending) :: Nil
+    val bb = Alias(b, "bb")()
+    val orderABB = SortOrder(a, Ascending) :: SortOrder(bb.toAttribute, 
Ascending) :: Nil
+
+    // SortExec([a, bb]) <- Project([a, b AS bb]) <- SortExec([a]) : the upper 
sort is over the
+    // renamed `bb`. Pushing it below the project rewrites `bb` back to `b`, 
widening the lower sort
+    // to [a, b]; the upper sort is dropped and the project re-exposes [a, bb] 
above.
+    val lower = SortExec(orderA, global = false, scan)
+    val project = ProjectExec(Seq(a, bb), lower)
+    val upper = SortExec(orderABB, global = false, project)
+
+    withSQLConf(SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "true") {
+      val rewritten = PushDownLocalSort(upper)
+      val sorts = rewritten.collect { case s: SortExec => s }
+      assert(sorts.length == 1, "the two sorts become one")
+      // The single remaining sort is the rewritten [a, b] sort in the 
project's input space.
+      assert(sorts.head.sortOrder == SortOrder(a, Ascending) :: SortOrder(b, 
Ascending) :: Nil)
+      assert(sorts.head.child.isInstanceOf[LocalTableScanExec])
+      // Above, the project re-exposes the [a, bb] ordering the dropped upper 
sort provided.
+      assert(SortOrder.orderingSatisfies(rewritten.outputOrdering, orderABB))
+      assert(ValidateRequirements.validate(rewritten, UnspecifiedDistribution))
+    }
+  }
+
+  test("Plan-level: push a wider sort down past three stacked sorts in a 
single pass") {
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    val c = AttributeReference("c", IntegerType)()
+    val scan = LocalTableScanExec(Seq(a, b, c), Nil, None)
+    val orderA = SortOrder(a, Ascending) :: Nil
+    val orderAB = SortOrder(a, Ascending) :: SortOrder(b, Ascending) :: Nil
+    val orderABC =
+      SortOrder(a, Ascending) :: SortOrder(b, Ascending) :: SortOrder(c, 
Ascending) :: Nil
+
+    // Sort([a,b,c]) <- Filter <- Sort([a,b]) <- Project <- Sort([a]) : the 
widest ordering is
+    // pushed all the way to the bottom sort, and both intermediate sorts are 
dropped, leaving one.
+    // Crossing the `Filter` requires the cardinality-reducer config.
+    val bottom = SortExec(orderA, global = false, scan)
+    val project = ProjectExec(Seq(a, b, c), bottom)
+    val middle = SortExec(orderAB, global = false, project)
+    val filter = FilterExec(IsNotNull(a), middle)
+    val top = SortExec(orderABC, global = false, filter)
+
+    withSQLConf(
+        SQLConf.PUSH_DOWN_LOCAL_SORT_ENABLED.key -> "true",
+        SQLConf.PUSH_DOWN_LOCAL_SORT_THROUGH_CARDINALITY_REDUCER_ENABLED.key 
-> "true") {
+      val rewritten = PushDownLocalSort(top)
+      val sorts = rewritten.collect { case s: SortExec => s }
+      assert(sorts.length == 1, "the three sorts become one")
+      assert(sorts.head.sortOrder == orderABC)
+      assert(sorts.head.child.isInstanceOf[LocalTableScanExec])
+      assert(SortOrder.orderingSatisfies(rewritten.outputOrdering, orderABC))
+      assert(ValidateRequirements.validate(rewritten, UnspecifiedDistribution))
+    }
+  }
+}
+
+class PushDownLocalSortSuite extends PushDownLocalSortSuiteBase
+  with DisableAdaptiveExecutionSuite
+
+class PushDownLocalSortSuiteAE extends PushDownLocalSortSuiteBase
+  with EnableAdaptiveExecutionSuite


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to