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

ulysses-you 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 185b0f3420d5 [SPARK-58099][SQL] Remove the local sort dangling below 
the shuffle added by skew join optimization
185b0f3420d5 is described below

commit 185b0f3420d5174fa18c16a77bed53bebb6df218
Author: Xiduo You <[email protected]>
AuthorDate: Wed Jul 15 14:56:03 2026 +0800

    [SPARK-58099][SQL] Remove the local sort dangling below the shuffle added 
by skew join optimization
    
    ### What changes were proposed in this pull request?
    
    This PR enhances `RemoveRedundantSorts` to remove a *dangling* local sort 
-- a local `SortExec` sitting directly below a shuffle that neither consumes 
nor exposes an ordering (both its `requiredChildOrdering` and `outputOrdering` 
are empty). Such a shuffle destroys the child ordering, so the sort has no 
effect and is dead.
    
    To make this effective for the skew-join case, `RemoveRedundantSorts` is 
moved after `OptimizeSkewedJoin` in the AQE `queryStagePreparationRules` 
(`AdaptiveSparkPlanExec`), and the same reorder is applied to the non-AQE 
`QueryExecution.preparations` to keep the two rule chains consistent. In both 
chains `RemoveRedundantSorts` now runs after `DisableUnnecessaryBucketedScan` 
(see the correctness note below).
    
    ### Why are the changes needed?
    
    Consider a stage with a `ShuffledHashJoin` feeding a `SortMergeJoin` on the 
same key, where the SHJ output partitioning already satisfies the SMJ 
requirement (no exchange in between, only the SMJ's local sort). When the SHJ 
is skewed and skew-join optimization is applied, an extra shuffle is inserted 
between the two joins. `EnsureRequirements` wraps that shuffle on top of the 
existing `Sort -> SHJ`, and adds a new sort above the shuffle for the SMJ. The 
original local sort is then lef [...]
    
    The current `RemoveRedundantSorts` cannot remove it: it only strips a sort 
whose child already satisfies the ordering, whereas this sort is dead because 
it sits directly under an ordering-destroying shuffle -- a different kind of 
redundancy. Running before `OptimizeSkewedJoin` also means it never sees the 
added shuffle.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. It only removes a redundant sort operator from the physical plan (and 
fixes the latent ordering-loss hazard described above).
    
    ### How was this patch tested?
    
    New UT `SPARK-58099` in `AdaptiveQueryExecSuite` reproduces the SHJ->SMJ 
skew-join scenario: with the rule disabled it asserts a local sort dangles 
below the added shuffle, and with the rule enabled it asserts the sort is 
removed while both joins remain skew joins and results are unchanged.
    
    New UTs in `RemoveRedundantSortsSuite` (both AE / non-AE variants):
    - `remove local sort dangling below a shuffle that does not require 
ordering` exercises the new removal branch directly on a physical plan (the 
dangling-sort shape cannot be produced from SQL/DataFrame because the logical 
`EliminateSorts` strips such a local sort before physical planning), covering 
local-sort removal, global-sort preservation, and the rule-disabled case.
    - `keep local sort satisfied only by a bucketed scan output ordering` is a 
regression test for the reorder-vs-`DisableUnnecessaryBucketedScan` correctness 
hazard; it fails with the old rule order and passes with the new one.
    
    Also verified `RemoveRedundantSortsSuite` (both AE / non-AE variants), 
`DisableUnnecessaryBucketedScanSuite`, and the full `AdaptiveQueryExecSuite` 
pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #57221 from ulysses-you/SPARK-58099.
    
    Authored-by: Xiduo You <[email protected]>
    Signed-off-by: Xiduo You <[email protected]>
    (cherry picked from commit bc6ca483b224e14c7bf7f6c7879ffc726381703c)
    Signed-off-by: Xiduo You <[email protected]>
---
 .../spark/sql/execution/QueryExecution.scala       | 12 ++--
 .../spark/sql/execution/RemoveRedundantSorts.scala | 28 +++++++--
 .../execution/adaptive/AdaptiveSparkPlanExec.scala |  7 ++-
 .../sql/execution/RemoveRedundantSortsSuite.scala  | 53 ++++++++++++++++-
 .../adaptive/AdaptiveQueryExecSuite.scala          | 69 ++++++++++++++++++++++
 5 files changed, 157 insertions(+), 12 deletions(-)

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 e37588faf8fb..2174c5899286 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
@@ -767,12 +767,16 @@ object QueryExecution {
       // `ReplaceHashWithSortAgg` needs to be added after `EnsureRequirements` 
to guarantee the
       // sort order of each node is checked to be valid.
       ReplaceHashWithSortAgg,
-      // `RemoveRedundantSorts` and `RemoveRedundantWindowGroupLimits` needs 
to be added after
-      // `EnsureRequirements` to guarantee the same number of partitions when 
instantiating
-      // PartitioningCollection.
-      RemoveRedundantSorts,
+      // `RemoveRedundantWindowGroupLimits` needs to be added after 
`EnsureRequirements` to
+      // guarantee the same number of partitions when instantiating 
PartitioningCollection.
       RemoveRedundantWindowGroupLimits,
       DisableUnnecessaryBucketedScan,
+      // `RemoveRedundantSorts` also needs to run after `EnsureRequirements` 
for the same reason.
+      // It must run after `DisableUnnecessaryBucketedScan`: disabling a 
bucketed scan drops its
+      // output ordering, so running sort-removal first could strip a sort 
that the scan appeared
+      // to satisfy and then silently lose that ordering. (This also matches 
the AQE rule order,
+      // see `AdaptiveSparkPlanExec.queryStagePreparationRules`.)
+      RemoveRedundantSorts,
       ApplyColumnarRulesAndInsertTransitions(
         sparkSession.sessionState.columnarRules, outputsColumnar = false),
       CollapseCodegenStages()) ++
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/RemoveRedundantSorts.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/RemoveRedundantSorts.scala
index 87c08ec865fe..85c29b230daf 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/RemoveRedundantSorts.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/RemoveRedundantSorts.scala
@@ -19,14 +19,25 @@ package org.apache.spark.sql.execution
 
 import org.apache.spark.sql.catalyst.expressions.SortOrder
 import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeLike
 import org.apache.spark.sql.internal.SQLConf
 
 /**
- * Remove redundant SortExec node from the spark plan. A sort node is 
redundant when
- * its child satisfies both its sort orders and its required child 
distribution. Note
- * this rule differs from the Optimizer rule EliminateSorts in that this rule 
also checks
- * if the child satisfies the required distribution so that it is safe to 
remove not only a
- * local sort but also a global sort when its child already satisfies required 
sort orders.
+ * Remove redundant SortExec node from the spark plan. A sort node is 
redundant when either:
+ *  - its child satisfies both its sort orders and its required child 
distribution. Note this
+ *    rule differs from the Optimizer rule EliminateSorts in that this rule 
also checks if the
+ *    child satisfies the required distribution so that it is safe to remove 
not only a local
+ *    sort but also a global sort when its child already satisfies required 
sort orders; or
+ *  - it is a local sort that is the direct child of a shuffle which neither 
requires its child
+ *    to be ordered (empty `requiredChildOrdering`) nor exposes an ordering 
itself (empty
+ *    `outputOrdering`). A regular shuffle does not preserve the child 
ordering, so such a local
+ *    sort has no effect on the query result and is dead. The 
`outputOrdering.isEmpty` guard is
+ *    what keeps this safe for a custom `ShuffleExchangeLike` that does 
preserve the child
+ *    ordering: such a shuffle reports a non-empty `outputOrdering`, its local 
sort is not dead
+ *    and must be kept. This commonly happens in AQE after 
`OptimizeSkewedJoin` inserts an extra
+ *    shuffle between two joins: the local sort that used to feed the upper 
join is left dangling
+ *    right below the newly added shuffle and ends up being computed in the 
wrong stage for
+ *    nothing.
  */
 object RemoveRedundantSorts extends Rule[SparkPlan] {
   def apply(plan: SparkPlan): SparkPlan = {
@@ -42,5 +53,12 @@ object RemoveRedundantSorts extends Rule[SparkPlan] {
         if SortOrder.orderingSatisfies(child.outputOrdering, orders) &&
           child.outputPartitioning.satisfies(s.requiredChildDistribution.head) 
=>
       child
+
+    case shuffle: ShuffleExchangeLike
+        if shuffle.requiredChildOrdering.head.isEmpty && 
shuffle.outputOrdering.isEmpty =>
+      shuffle.child match {
+        case SortExec(_, false, sortChild, _) => 
shuffle.withNewChildren(Seq(sortChild))
+        case _ => shuffle
+      }
   }
 }
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 7040ab51cf51..f74115d98369 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
@@ -132,10 +132,13 @@ case class AdaptiveSparkPlanExec(
       // turn a hash aggregate into a sort aggregate.
       ConvertSortMergeJoinToShuffledHashJoin(ensureRequirements),
       ReplaceHashWithSortAgg,
-      RemoveRedundantSorts,
       RemoveRedundantWindowGroupLimits,
       DisableUnnecessaryBucketedScan,
-      OptimizeSkewedJoin(ensureRequirements)
+      OptimizeSkewedJoin(ensureRequirements),
+      // `RemoveRedundantSorts` runs after `OptimizeSkewedJoin` so that it can 
also clean up the
+      // local sort left dangling right below the extra shuffle that skew join 
optimization may
+      // insert between two joins.
+      RemoveRedundantSorts
     ) ++ context.session.sessionState.adaptiveRulesHolder.queryStagePrepRules
   }
 
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/RemoveRedundantSortsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/RemoveRedundantSortsSuite.scala
index 3cba30079cdb..9892a1c46242 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/RemoveRedundantSortsSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/RemoveRedundantSortsSuite.scala
@@ -18,11 +18,14 @@
 package org.apache.spark.sql.execution
 
 import org.apache.spark.sql.DataFrame
-import org.apache.spark.sql.catalyst.plans.physical.{RangePartitioning, 
UnknownPartitioning}
+import org.apache.spark.sql.catalyst.expressions.{Ascending, 
AttributeReference, SortOrder}
+import org.apache.spark.sql.catalyst.plans.physical.{HashPartitioning, 
RangePartitioning, UnknownPartitioning}
 import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanHelper, 
DisableAdaptiveExecutionSuite, EnableAdaptiveExecutionSuite}
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeExec
 import org.apache.spark.sql.execution.joins.ShuffledJoin
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.IntegerType
 
 
 abstract class RemoveRedundantSortsSuiteBase
@@ -139,6 +142,54 @@ abstract class RemoveRedundantSortsSuiteBase
     }
   }
 
+  test("SPARK-58099: remove local sort dangling below a shuffle that does not 
require ordering") {
+    // A local sort directly below a round-robin repartition survives logical 
optimization
+    // (`EliminateSorts` only recurses through 
Sort/Join/order-irrelevant-Aggregate parents, and
+    // `CollapseRepartition` removes only a global sort under 
`RepartitionByExpression` or any sort
+    // under `RebalancePartitions` -- never a sort under a round-robin 
`Repartition`), so it reaches
+    // the physical plan as `Shuffle(RoundRobin) <- Sort(local)` and is 
removed by the new branch.
+    // The source must be unsorted so the local sort is not itself elided as 
redundant.
+    withTempView("t") {
+      Seq(3, 1, 2, 5, 4).toDF("key").createOrReplaceTempView("t")
+      val query = "SELECT /*+ REPARTITION(3) */ * FROM (SELECT key FROM t SORT 
BY key)"
+      checkSorts(query, 0, 1)
+    }
+
+    // A global sort below such a shuffle must be kept: it carries its own 
distribution requirement
+    // and is not the dead within-partition sort this rule targets. This shape 
is not reachable via
+    // SQL/DataFrame, so the rule is applied directly to a physical plan.
+    val attr = AttributeReference("key", IntegerType)()
+    val scan = LocalTableScanExec(Seq(attr), Nil, None)
+    val globalSort = SortExec(SortOrder(attr, Ascending) :: Nil, global = 
true, scan)
+    withSQLConf(SQLConf.REMOVE_REDUNDANT_SORTS_ENABLED.key -> "true") {
+      val danglingGlobal = ShuffleExchangeExec(HashPartitioning(Seq(attr), 5), 
globalSort)
+      
assert(RemoveRedundantSorts(danglingGlobal).find(_.isInstanceOf[SortExec]).isDefined)
+    }
+  }
+
+  test("SPARK-58099: keep local sort satisfied only by a bucketed scan output 
ordering") {
+    // With `RemoveRedundantSorts` running before 
`DisableUnnecessaryBucketedScan`, the local sort
+    // could be stripped based on the bucketed scan's output ordering and then 
the ordering would
+    // be silently lost once `DisableUnnecessaryBucketedScan` disables the 
scan. Running
+    // `RemoveRedundantSorts` after `DisableUnnecessaryBucketedScan` closes 
that hole.
+    withSQLConf(
+      SQLConf.REMOVE_REDUNDANT_SORTS_ENABLED.key -> "true",
+      SQLConf.LEGACY_BUCKETED_TABLE_SCAN_OUTPUT_ORDERING.key -> "true",
+      SQLConf.AUTO_BUCKETED_SCAN_ENABLED.key -> "true") {
+      withTable("t") {
+        // A single file per bucket so the bucketed scan reports an output 
ordering.
+        spark.range(100).selectExpr("id as i").repartition(1)
+          .write.format("parquet").bucketBy(8, 
"i").sortBy("i").saveAsTable("t")
+        val df = sql("SELECT * FROM t SORT BY i")
+        val plan = df.queryExecution.executedPlan
+        // The bucketed scan is disabled since there is no 
interesting-partition operator above it.
+        assert(collect(plan) { case s: FileSourceScanExec if s.bucketedScan => 
s }.isEmpty)
+        // The local sort must be kept, otherwise the within-partition 
ordering would be lost.
+        assert(collect(plan) { case s: SortExec => s }.nonEmpty)
+      }
+    }
+  }
+
   test("SPARK-33472: shuffled join with different left and right side 
partition numbers") {
     withTempView("t1", "t2") {
       spark.range(0, 100, 1, 2).select($"id" as 
"key").createOrReplaceTempView("t1")
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
index 4b57546475ac..7446168c84fc 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
@@ -831,6 +831,75 @@ class AdaptiveQueryExecSuite
     }
   }
 
+  test("SPARK-58099: Remove local sort dangling below the shuffle added by 
skew join") {
+    // A ShuffledHashJoin feeding a SortMergeJoin in the same stage. When the 
SHJ is skewed and
+    // skew-join optimization is force-applied, an extra shuffle is inserted 
between the two joins.
+    // The local sort that used to feed the SMJ is then left dangling right 
below that new shuffle,
+    // computed in the wrong stage for nothing. `RemoveRedundantSorts` should 
strip it.
+
+    // Collect local sorts sitting directly below a shuffle. In the final plan 
the sort may be
+    // wrapped in a WholeStageCodegenExec, so unwrap it before matching.
+    def collectDanglingSorts(plan: SparkPlan): Seq[SortExec] = {
+      def unwrap(p: SparkPlan): SparkPlan = p match {
+        case w: WholeStageCodegenExec => unwrap(w.child)
+        case other => other
+      }
+      collect(plan) {
+        case sh: ShuffleExchangeLike if 
unwrap(sh.child).isInstanceOf[SortExec] &&
+          !unwrap(sh.child).asInstanceOf[SortExec].global =>
+          unwrap(sh.child).asInstanceOf[SortExec]
+      }
+    }
+
+    def runQuery(): SparkPlan = {
+      val q =
+        """
+          |SELECT /*+ SHUFFLE_HASH(skewData2), MERGE(data3) */ skewData1.key1
+          |FROM skewData1 JOIN skewData2 ON skewData1.key1 = skewData2.key2 - 
100
+          |JOIN data3 ON skewData1.key1 = data3.key3 - 200
+          |""".stripMargin
+      val (_, adaptivePlan) = runAdaptiveAndVerifyResult(q)
+      adaptivePlan
+    }
+
+    withSQLConf(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.ADAPTIVE_FORCE_OPTIMIZE_SKEWED_JOIN.key -> "true",
+      SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD.key -> "100",
+      SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+      SQLConf.SHUFFLE_PARTITIONS.key -> "10",
+      SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "false") {
+      withTempView("skewData1", "skewData2", "data3") {
+        spark.range(0, 300, 1, 10)
+          .selectExpr("id % 3 as key1", "id as 
value1").createOrReplaceTempView("skewData1")
+        spark.range(0, 300, 1, 10)
+          .selectExpr("(id % 3) + 100 as key2", "id as value2")
+          .createOrReplaceTempView("skewData2")
+        spark.range(0, 300, 1, 10)
+          .selectExpr("(id % 3) + 200 as key3", "id as value3")
+          .createOrReplaceTempView("data3")
+
+        // Both joins should be optimized as skew joins so that the extra 
shuffle is introduced.
+        val disabledPlan = 
withSQLConf(SQLConf.REMOVE_REDUNDANT_SORTS_ENABLED.key -> "false") {
+          runQuery()
+        }
+        assert(collect(disabledPlan) { case j: ShuffledHashJoinExec => j 
}.exists(_.isSkewJoin))
+        assert(collect(disabledPlan) { case j: SortMergeJoinExec => j 
}.exists(_.isSkewJoin))
+        // Without the rule, a local sort dangles right below the shuffle 
added on top of the SHJ.
+        assert(collectDanglingSorts(disabledPlan).nonEmpty)
+
+        val enabledPlan = 
withSQLConf(SQLConf.REMOVE_REDUNDANT_SORTS_ENABLED.key -> "true") {
+          runQuery()
+        }
+        assert(collect(enabledPlan) { case j: ShuffledHashJoinExec => j 
}.exists(_.isSkewJoin))
+        assert(collect(enabledPlan) { case j: SortMergeJoinExec => j 
}.exists(_.isSkewJoin))
+        // With the rule, the dangling local sort below the shuffle is removed.
+        assert(collectDanglingSorts(enabledPlan).isEmpty)
+      }
+    }
+  }
+
   test("SPARK-29544: adaptive skew join with different join types") {
     Seq("SHUFFLE_MERGE", "SHUFFLE_HASH").foreach { joinHint =>
       def getJoinNode(plan: SparkPlan): Seq[ShuffledJoin] = if (joinHint == 
"SHUFFLE_MERGE") {


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

Reply via email to