viirya commented on code in PR #57181:
URL: https://github.com/apache/spark/pull/57181#discussion_r3581103414


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -287,7 +288,7 @@ case object BuildRight extends BuildSide
 
 case object BuildLeft extends BuildSide
 
-trait JoinSelectionHelper extends Logging {
+trait JoinSelectionHelper extends SQLConfHelper with Logging {

Review Comment:
   This `SQLConfHelper` is now a leftover: it was added for 
`preferShuffledHashJoin` when that lived here, but the method has moved into 
the physical rule (which already gets `conf` from `Rule`), and every remaining 
method in this trait takes `conf: SQLConf` explicitly. It can be reverted to 
keep the trait's explicit-conf style.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/LiteralExpressionSuite.scala:
##########
@@ -855,4 +855,48 @@ class LiteralExpressionSuite extends SparkFunSuite with 
ExpressionEvalHelper {
     assert(Literal(UTF8String.fromString("x"), StringType("UTF8_LCASE")).sql 
===
       "'x' collate UTF8_LCASE")
   }
+
+  test("valueSizeInBytes") {
+    // A null value reports the type's default size.
+    assert(Literal.create(null, StringType).valueSizeInBytes === 
Some(StringType.defaultSize))
+    assert(Literal.create(null, IntegerType).valueSizeInBytes === 
Some(IntegerType.defaultSize))
+
+    // Fixed-length types report their default size.
+    assert(Literal(1).valueSizeInBytes === Some(IntegerType.defaultSize))
+    assert(Literal(1L).valueSizeInBytes === Some(LongType.defaultSize))
+    assert(Literal(1.0).valueSizeInBytes === Some(DoubleType.defaultSize))
+    assert(Literal(true).valueSizeInBytes === Some(BooleanType.defaultSize))
+    assert(Literal(Decimal(1), DecimalType(10, 0)).valueSizeInBytes ===
+      Some(DecimalType(10, 0).defaultSize))
+
+    // Variable-length string / binary report their real byte length, not the 
default size.
+    assert(Literal(UTF8String.fromString(""), StringType).valueSizeInBytes === 
Some(0))
+    assert(Literal(UTF8String.fromString("abc"), StringType).valueSizeInBytes 
=== Some(3))
+    // A multi-byte UTF-8 character counts its encoded bytes (U+00E9 encodes 
to 2 bytes).
+    assert(Literal(UTF8String.fromString("\u00e9"), 
StringType).valueSizeInBytes === Some(2))

Review Comment:
   This is why the lint job is still failing after the style fix: scalastyle 
flags `nonascii` at 876:41 even though the source is now a pure-ASCII `\u00e9` 
escape - the scalariform lexer decodes unicode escapes before 
`NonASCIICharacterChecker` sees the text, so the escape counts as the character 
itself. The escape can't dodge the check; build the string without one, e.g.:
   
   ```scala
   // U+00E9 encodes to 2 bytes in UTF-8.
   assert(Literal(UTF8String.fromString(new String(Character.toChars(0xE9))), 
StringType)
     .valueSizeInBytes === Some(2))
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/ConvertSortMergeJoinToShuffledHashJoin.scala:
##########
@@ -0,0 +1,257 @@
+/*
+ * 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.adaptive
+
+import scala.annotation.tailrec
+
+import org.apache.spark.MapOutputStatistics
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, CaseWhen, 
Cast, Coalesce, Expression, If, Literal, String2TrimExpression, Substring, 
UnsafeRow}
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight, 
JoinSelectionHelper}
+import org.apache.spark.sql.catalyst.plans.LeftExistence
+import org.apache.spark.sql.catalyst.plans.logical.{Join, SHUFFLE_MERGE}
+import 
org.apache.spark.sql.catalyst.plans.logical.statsEstimation.EstimationUtils
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.{CollectMetricsExec, FilterExec, 
ProjectExec, SortExec, SparkPlan}
+import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
+import org.apache.spark.sql.execution.exchange.{ENSURE_REQUIREMENTS, 
EnsureRequirements}
+import org.apache.spark.sql.execution.joins.{BaseJoinExec, 
ShuffledHashJoinExec, SortMergeJoinExec}
+import org.apache.spark.sql.execution.window.{WindowExecBase, 
WindowGroupLimitExec}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Converts a [[SortMergeJoinExec]] into a [[ShuffledHashJoinExec]] during 
adaptive execution when
+ * a build side's materialized shuffle statistics show it is small enough for 
a local hash map.
+ *
+ * This runs on the physical plan and owns the shuffled-hash-over-sort-merge 
selection that AQE
+ * makes from materialized shuffle statistics. It is gated by
+ * `spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` 
(default true, the master
+ * switch), and has two modes:
+ *   - Default: it looks through the sort merge join's own required 
[[SortExec]] to reach a
+ *     *direct* input shuffle.
+ *   - Behind 
`...convertSortMergeJoinToShuffledHashJoin.lookThroughOperators.enabled`: it
+ *     additionally looks through non-shuffle operators (aggregate, project, 
filter, window,
+ *     left-existence join) sitting above the shuffle.
+ *
+ * The swap is shuffle-free since both joins are `ShuffledJoin`s with the same 
distribution and
+ * partitioning; only the child sorts become unnecessary. As a shuffled hash 
join loses the sort
+ * merge join's output ordering, [[EnsureRequirements]] is re-run to restore 
any ordering an
+ * ancestor still needs, and AQE's [[CostEvaluator]] decides whether to adopt 
the converted plan.
+ *
+ * A shuffled hash join builds a non-spillable local hash map, so the 
traversed operators must not
+ * blow up the build size that the input shuffle statistics estimate:
+ *   - the traversal only looks through an operator whose output expressions 
are all size-bounded
+ *     (see [[isSizeBoundedExpr]]), so no operator can widen a row in a way 
the shuffle statistics
+ *     cannot see; and
+ *   - the build-side estimate is scaled by [[wideningFactor]] to account for 
the width change the
+ *     traversed operators do introduce.
+ */
+case class ConvertSortMergeJoinToShuffledHashJoin(ensureRequirements: 
EnsureRequirements)
+  extends Rule[SparkPlan] with JoinSelectionHelper {
+
+  private def preferShuffledHashJoin(
+      mapStats: MapOutputStatistics,
+      sizeInBytesFactor: Double): Boolean = {
+    val maxShuffledHashJoinLocalMapThreshold =
+      conf.getConf(SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD)
+    val advisoryPartitionSize = 
conf.getConf(SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES)
+    advisoryPartitionSize <= maxShuffledHashJoinLocalMapThreshold &&
+      mapStats.bytesByPartitionId.forall(
+        _ * sizeInBytesFactor <= maxShuffledHashJoinLocalMapThreshold)
+  }
+
+  /**
+   * The estimated per-row byte-size ratio of the build subtree's output to 
its input shuffle's
+   * output, i.e. how much the traversed operators widen each row. The 
traversed operators never
+   * increase the row count (`N_build <= N_shuffle`), so scaling the input 
shuffle bytes by this
+   * ratio keeps them a valid upper bound on the hash-map build size once row 
width is accounted
+   * for: `buildSize = N_build * buildRowWidth <= shuffleBytes * 
(buildRowWidth / shuffleRowWidth)`.
+   *
+   * Floored at 
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.minWideningFactor`
+   * (default 1.0). Unlike `SizeInBytesOnlyStatsPlanVisitor`, which computes a 
best-effort size and
+   * lets a narrowing operator shrink it, the default keeps a conservative 
bound for a non-spillable
+   * build: `getSizePerRow` under-estimates a variable-width column (it uses 
`defaultSize`), so a
+   * `factor < 1` could push the scaled bytes below the real build size and 
reintroduce the
+   * out-of-memory risk, whereas the raw shuffle bytes are always a valid 
bound when the build side
+   * is no wider than the shuffle row. Raising the floor above 1.0 is more 
conservative still.
+   */
+  private def wideningFactor(buildOutput: Seq[Attribute], shuffleOutput: 
Seq[Attribute]): Double = {
+    val buildRowSize = EstimationUtils.getSizePerRow(buildOutput).toDouble
+    val shuffleRowSize = EstimationUtils.getSizePerRow(shuffleOutput).toDouble
+    math.max(conf.convertSortMergeJoinToShuffledHashJoinMinWideningFactor,
+      buildRowSize / shuffleRowSize)
+  }
+
+  private def hasSortMergeJoinHint(smj: SortMergeJoinExec): Boolean = 
smj.logicalLink.exists {
+    case j: Join =>
+      j.hint.leftHint.exists(_.strategy.contains(SHUFFLE_MERGE)) ||
+        j.hint.rightHint.exists(_.strategy.contains(SHUFFLE_MERGE))
+    case _ => false
+  }
+
+  override def apply(plan: SparkPlan): SparkPlan = {
+    if (!conf.convertSortMergeJoinToShuffledHashJoinEnabled) {
+      return plan
+    }
+    val lookThroughOperatorsEnabled =
+      conf.convertSortMergeJoinToShuffledHashJoinLookThroughOperatorsEnabled
+    val optimizedPlan = plan.transformUp {
+      case smj @ SortMergeJoinExec(leftKeys, rightKeys, joinType, condition,
+        left, right, isSkewJoin)

Review Comment:
   The previous revision matched `isSkewJoin = false` explicitly; this now 
matches any value and propagates it. `true` is currently unreachable - 
`OptimizeSkewedJoin` runs later in the same preparation-rule sequence, and the 
skew-marked joins it produces have `AQEShuffleReadExec` children that 
`findShuffleStage` doesn't match - but that's an implicit invariant. If some 
rule ever breaks it, this would silently produce an SHJ claiming `isSkewJoin = 
true` whose children lack the skew-split readers. I'd reinstate the explicit 
`false` match (or a comment stating the invariant).



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -2461,6 +2466,412 @@ class AdaptiveQueryExecSuite
     }
   }
 
+  test("SPARK-58084: Convert sort merge join to shuffled hash join through 
operators") {
+    withTempView("t1", "t2", "t3") {
+      spark.sparkContext.parallelize(
+        (1 to 100).map(i => TestData(i, i.toString)), 10)
+        .toDF("c1", "c2").createOrReplaceTempView("t1")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, i.toString)), 5)
+        .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+      // The t2 side has a non-shuffle operator (aggregate, optionally with a 
filter) between the
+      // join and its input shuffle, so the default direct-shuffle path does 
not reach the shuffle;
+      // only the look-through mode converts it.
+      val queries = Seq(
+        "SELECT t1.c1, x.cnt FROM t1 JOIN " +
+          "(SELECT c1, count(*) AS cnt FROM t2 GROUP BY c1) x ON t1.c1 = x.c1",
+        "SELECT t1.c1, x.cnt FROM t1 JOIN " +
+          "(SELECT c1, count(*) AS cnt FROM t2 GROUP BY c1 HAVING count(*) >= 
0) x " +
+          "ON t1.c1 = x.c1")
+
+      // t1 partition size: [926, 729, 731]; t2 (aggregated) side: [372, 126, 
0]. With a small
+      // advisory partition size and a local map threshold of 500, only the t2 
side has all
+      // partitions within the threshold, so the join is converted with the t2 
side as build side.
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"500") {
+        queries.foreach { query =>
+          // Look-through enabled: the sort merge join is converted to a 
shuffled hash join.
+          withSQLConf(
+            lookThroughOperatorsKey -> "true") {
+            val (origin, adaptive) = runAdaptiveAndVerifyResult(query)
+            assert(findTopLevelSortMergeJoin(origin).size === 1)
+            val shj = findTopLevelShuffledHashJoin(adaptive)
+            assert(shj.size === 1, s"expected a shuffled hash join for query: 
$query")
+            assert(shj.head.buildSide == BuildRight)
+            assert(findTopLevelSortMergeJoin(adaptive).isEmpty)
+          }
+          // Look-through disabled (default): the aggregate blocks the 
direct-shuffle path, so the
+          // join stays a sort merge join.
+          withSQLConf(
+            lookThroughOperatorsKey -> "false") {
+            val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+            assert(findTopLevelShuffledHashJoin(adaptive).isEmpty,
+              s"expected no shuffled hash join for query: $query")
+            assert(findTopLevelSortMergeJoin(adaptive).size === 1,
+              s"expected a sort merge join for query: $query")
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-58084: Do not convert when an operator adds a variable-width 
column") {
+    withTempView("t1", "t2") {
+      spark.sparkContext.parallelize(
+        (1 to 100).map(i => TestData(i, i.toString)), 10)
+        .toDF("c1", "c2").createOrReplaceTempView("t1")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, i.toString)), 5)
+        .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+      // The shuffle below the aggregate is tiny, but the aggregate widens 
each build row with a
+      // large variable-width string (`repeat(max(c2), 500)`), so the shuffle 
bytes badly
+      // under-estimate the non-spillable hash-map build size. The traversal 
must stop at that
+      // widening operator and leave the join as a sort merge join, even 
though the shuffle looks
+      // small enough for a local hash map. The wide column is selected in the 
output so column
+      // pruning cannot drop it before the join.
+      val query =
+        "SELECT t1.c1, x.wide FROM t1 JOIN " +
+          "(SELECT c1, repeat(max(c2), 500) AS wide FROM t2 GROUP BY c1) x ON 
t1.c1 = x.c1"
+
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"500",
+        lookThroughOperatorsKey -> "true") {
+        val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+        assert(findTopLevelShuffledHashJoin(adaptive).isEmpty,
+          "a widening aggregate above the shuffle must keep the join as a sort 
merge join")
+        assert(findTopLevelSortMergeJoin(adaptive).size === 1)
+      }
+    }
+  }
+
+  test("SPARK-58084: Convert through size-bounded (non-widening) operators") {
+    withTempView("t1", "t2") {
+      spark.sparkContext.parallelize(
+        (1 to 100).map(i => TestData(i, i.toString)), 10)
+        .toDF("c1", "c2").createOrReplaceTempView("t1")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, i.toString)), 5)
+        .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+      // The aggregate emits a variable-width string column, but only through 
size-bounded
+      // expressions: `max` selects an existing value, `cast` and `substring` 
cannot widen it. The
+      // traversal must look through them and still convert the join, unlike 
the `repeat(...)` case.
+      val query =
+        "SELECT t1.c1, x.m, x.s FROM t1 JOIN " +
+          "(SELECT c1, substring(max(c2), 1, 1) AS m, cast(count(*) AS string) 
AS s " +
+          "FROM t2 GROUP BY c1) x ON t1.c1 = x.c1"
+
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"100000",
+        lookThroughOperatorsKey -> "true") {
+        val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+        val shj = findTopLevelShuffledHashJoin(adaptive)
+        assert(shj.size === 1,
+          "size-bounded operators above the shuffle must not block the 
conversion")
+        assert(shj.head.buildSide == BuildRight)
+        assert(findTopLevelSortMergeJoin(adaptive).isEmpty)
+      }
+    }
+  }
+
+  test("SPARK-58084: MinWideningFactor makes the size bound more 
conservative") {
+    withTempView("t1", "t2") {
+      spark.sparkContext.parallelize(
+        (1 to 100).map(i => TestData(i, i.toString)), 10)
+        .toDF("c1", "c2").createOrReplaceTempView("t1")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, i.toString)), 5)
+        .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+      // The t2 (aggregated) build side fits the local map threshold at the 
default widening factor,
+      // so the join converts. A large minWideningFactor scales the estimated 
build size past the
+      // threshold, so the conversion is rejected and the join stays a sort 
merge join.
+      val query =
+        "SELECT t1.c1, x.cnt FROM t1 JOIN " +
+          "(SELECT c1, count(*) AS cnt FROM t2 GROUP BY c1) x ON t1.c1 = x.c1"
+
+      def convertsWith(minWideningFactor: String): Boolean = {
+        var converted = false
+        withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+          SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+          SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+          SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"500",
+          lookThroughOperatorsKey -> "true",
+          
SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_MIN_WIDENING_FACTOR.key
 ->
+            minWideningFactor) {
+          val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+          converted = findTopLevelShuffledHashJoin(adaptive).nonEmpty
+        }
+        converted
+      }
+
+      // Default factor: the build side fits, so the join converts.
+      assert(convertsWith("1.0"), "the join should convert at the default 
widening factor")
+      // A large factor scales the estimated build size past the threshold, 
rejecting the
+      // conversion.
+      assert(!convertsWith("1000.0"), "a large minWideningFactor should reject 
the conversion")
+    }
+  }
+
+  test("SPARK-58084: Convert sort merge join keeps required ordering valid") {
+    withTempView("small1", "small2", "big") {
+      spark.sparkContext.parallelize(
+        (1 to 20).map(i => TestData(i, i.toString)), 4)
+        .toDF("c1", "c2").createOrReplaceTempView("small1")
+      spark.sparkContext.parallelize(
+        (1 to 20).map(i => TestData(i, i.toString)), 4)
+        .toDF("c1", "c2").createOrReplaceTempView("small2")
+      spark.sparkContext.parallelize(
+        (1 to 4000).map(i => TestData(i % 20 + 1, i.toString)), 4)
+        .toDF("c1", "c2").createOrReplaceTempView("big")
+
+      // The inner join over the two small tables is convertible to a shuffled 
hash join. The outer
+      // join is pinned to a sort merge join with a MERGE hint, and it 
requires its (left) child
+      // ordered on the join key. When the inner join is converted to a 
shuffled hash join
+      // (ordering Nil), EnsureRequirements must re-insert the sort above it 
so the outer sort merge
+      // join's required ordering is still satisfied and the result is correct.
+      val query = "SELECT /*+ MERGE(big) */ small1.c1 FROM " +
+        "small1 JOIN small2 ON small1.c1 = small2.c1 " +
+        "JOIN big ON small1.c1 = big.c1"
+
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"100000",
+        
SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key -> 
"true") {
+        val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+        // The inner join is converted; the outer join stays a sort merge join 
whose (left) child
+        // ordering is re-established by EnsureRequirements, so the plan 
remains valid.
+        val smj = findTopLevelSortMergeJoin(adaptive)
+        assert(smj.size === 1)
+        assert(smj.head.left.outputOrdering.nonEmpty,
+          "outer sort merge join must keep its left child ordered on the join 
key")
+        assert(findTopLevelShuffledHashJoin(adaptive).size === 1)
+      }
+    }
+  }
+
+  test("SPARK-58084: SimpleCostEvaluator counts local sorts as a 
lower-priority tiebreaker") {
+    def leaf: SparkPlan = CostTestLeafExec()
+    def shuffle(child: SparkPlan): SparkPlan = 
ShuffleExchangeExec(SinglePartition, child)
+    def localSort(child: SparkPlan): SparkPlan =
+      SortExec(SortOrder(child.output.head, Ascending) :: Nil, global = false, 
child)
+
+    val evaluator = SimpleCostEvaluator(forceOptimizeSkewedJoin = false, 
countLocalSort = true)
+    def cost(plan: SparkPlan): Cost = evaluator.evaluateCost(plan)
+
+    // Same number of shuffles: fewer local sorts is cheaper.
+    val oneShuffleTwoSorts = localSort(localSort(shuffle(leaf)))
+    val oneShuffleOneSort = localSort(shuffle(leaf))
+    val oneShuffleNoSort = shuffle(leaf)
+    assert(cost(oneShuffleOneSort).compare(cost(oneShuffleTwoSorts)) < 0)
+    assert(cost(oneShuffleNoSort).compare(cost(oneShuffleOneSort)) < 0)
+
+    // The number of shuffles dominates: a plan with more shuffles is costlier 
even with no sorts.
+    val twoShufflesNoSort = shuffle(shuffle(leaf))
+    assert(cost(oneShuffleTwoSorts).compare(cost(twoShufflesNoSort)) < 0)
+
+    // When countLocalSort is disabled, local sorts do not affect the cost.
+    val noSortEvaluator = SimpleCostEvaluator(
+      forceOptimizeSkewedJoin = false, countLocalSort = false)
+    assert(noSortEvaluator.evaluateCost(oneShuffleTwoSorts)
+      .compare(noSortEvaluator.evaluateCost(oneShuffleNoSort)) === 0)
+
+    // Skew join dominates, ahead of shuffles and sorts: with 
forceOptimizeSkewedJoin, a plan with
+    // a skew join is cheaper than one without, even if the skew-join plan has 
more shuffles and
+    // local sorts.
+    def join(l: SparkPlan, r: SparkPlan, isSkew: Boolean): SparkPlan =
+      SortMergeJoinExec(l.output.take(1), r.output.take(1), Inner, None, l, r, 
isSkewJoin = isSkew)
+    val skewEvaluator = SimpleCostEvaluator(forceOptimizeSkewedJoin = true, 
countLocalSort = true)
+    // Skew-join plan: 1 skew join, 3 shuffles, 2 local sorts.
+    val withSkewJoin = skewEvaluator.evaluateCost(
+      join(localSort(shuffle(shuffle(leaf))), localSort(shuffle(leaf)), isSkew 
= true))
+    // Non-skew plan: 0 skew joins, 2 shuffles, 0 local sorts.
+    val withoutSkewJoin = skewEvaluator.evaluateCost(
+      join(shuffle(leaf), shuffle(leaf), isSkew = false))
+    assert(withSkewJoin.compare(withoutSkewJoin) < 0)
+  }
+
+  test("SPARK-58084: Do not convert sort merge join when it adds local sorts") 
{
+    withTempView("big", "small") {
+      spark.sparkContext.parallelize(
+        (1 to 2000).map(i => TestData(i % 20 + 1, i.toString)), 4)
+        .toDF("k", "v").createOrReplaceTempView("big")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, i.toString)), 4)
+        .toDF("k", "v").createOrReplaceTempView("small")
+
+      // Both join sides are sort aggregates grouped by the join key, so each 
child is already
+      // ordered on the key for free and the sort merge join needs no explicit 
child sort. A parent
+      // window partitions by the right join key. A sort merge inner join 
keeps both sides' key
+      // orderings, satisfying the window; a shuffled hash join with 
build-right keeps only the left
+      // ordering (see HashJoin.outputOrdering), so converting it forces an 
extra local sort above
+      // the window. The conversion is therefore only beneficial without 
counting local sorts.
+      val query =
+        "SELECT l.k, count(*) OVER (PARTITION BY r.k) c " +
+          "FROM (SELECT k, count(*) c FROM big GROUP BY k) l " +
+          "JOIN (SELECT k, count(*) c FROM small GROUP BY k) r ON l.k = r.k"
+
+      def countLocalSorts(plan: SparkPlan): Int = collect(plan) {
+        case s: SortExec if !s.global => s
+      }.size
+
+      // Force sort aggregate so each join child is ordered on the key for 
free.
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.USE_HASH_AGG.key -> "false",
+        SQLConf.USE_OBJECT_HASH_AGG.key -> "false",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"500",
+        lookThroughOperatorsKey -> "true") {
+        // Not counting local sorts: the conversion is adopted even though it 
adds a local sort.
+        
withSQLConf(SQLConf.ADAPTIVE_COST_EVALUATOR_COUNT_LOCAL_SORT_ENABLED.key -> 
"false") {
+          val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+          assert(findTopLevelShuffledHashJoin(adaptive).size === 1)
+          assert(findTopLevelSortMergeJoin(adaptive).isEmpty)
+          assert(countLocalSorts(adaptive) == 5)
+        }
+        // Counting local sorts: the converted plan has more local sorts, so 
it is rejected and the
+        // sort merge join is kept.
+        
withSQLConf(SQLConf.ADAPTIVE_COST_EVALUATOR_COUNT_LOCAL_SORT_ENABLED.key -> 
"true") {
+          val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+          assert(findTopLevelShuffledHashJoin(adaptive).isEmpty)
+          assert(findTopLevelSortMergeJoin(adaptive).size === 1)
+          assert(countLocalSorts(adaptive) == 4)
+        }
+      }
+    }
+  }
+
+  test("SPARK-58084: Do not convert sort merge join with non-binary-stable 
(collated) keys") {
+    withTempView("t1", "t2") {
+      spark.sparkContext.parallelize(
+        (1 to 100).map(i => TestData(i, s"v$i")), 10)
+        .toDF("c1", "c2").createOrReplaceTempView("t1")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, s"v$i")), 5)
+        .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+      // A UTF8_LCASE key is orderable (so a sort merge join is planned) but 
not binary-stable. When
+      // the equi-condition wraps the key (here `concat(...)`), 
`RewriteCollationJoin` does not
+      // inject a `CollationKey`, so the physical join keys stay 
non-binary-stable. A shuffled hash
+      // join matches keys by `UnsafeRow` binary equality, which would return 
wrong results, so the
+      // conversion must skip such joins even with the config enabled - 
mirroring the
+      // `hashJoinSupported` guard on the other SHJ-planning paths.
+      val query =
+        "SELECT t1.c2 FROM t1 JOIN t2 ON " +
+          "concat(cast(t1.c2 AS STRING COLLATE UTF8_LCASE), 'x') = " +
+          "concat(cast(t2.c2 AS STRING COLLATE UTF8_LCASE), 'x')"
+
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"100000",
+        
SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key -> 
"true") {
+        val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+        assert(findTopLevelShuffledHashJoin(adaptive).isEmpty,
+          "non-binary-stable collated keys must keep the join as a sort merge 
join")
+        assert(findTopLevelSortMergeJoin(adaptive).size === 1)
+      }
+    }
+  }
+
+  test("SPARK-58084: Do not convert sort merge join requested with an explicit 
MERGE hint") {
+    withTempView("t1", "t2") {
+      spark.sparkContext.parallelize(
+        (1 to 100).map(i => TestData(i, i.toString)), 10)
+        .toDF("c1", "c2").createOrReplaceTempView("t1")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, i.toString)), 5)
+        .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+      // The join is convertible by size, but the user explicitly asked for a 
sort merge join with
+      // a MERGE hint. The conversion must respect the hint and keep the sort 
merge join, never
+      // overriding an existing SHUFFLE_MERGE join strategy hint.
+      val query = "SELECT /*+ MERGE(t1, t2) */ t1.c1, t2.c2 FROM t1 JOIN t2 ON 
t1.c1 = t2.c1"
+
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"100000",
+        
SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key -> 
"true") {
+        val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+        assert(findTopLevelShuffledHashJoin(adaptive).isEmpty,
+          "an explicit MERGE hint must keep the join as a sort merge join")
+        assert(findTopLevelSortMergeJoin(adaptive).size === 1)
+      }
+    }
+  }
+
+  test("SPARK-58084: Do not convert when a project adds a large folded 
constant") {
+    withTempView("t1", "t2") {
+      spark.sparkContext.parallelize(
+        (1 to 100).map(i => TestData(i, i.toString)), 10)
+        .toDF("c1", "c2").createOrReplaceTempView("t1")
+      spark.sparkContext.parallelize(
+        (1 to 10).map(i => TestData(i, i.toString)), 5)
+        .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+      val query =
+        "SELECT t1.c1, x.wide FROM t1 JOIN " +
+          "(SELECT c2, coalesce(c2, repeat('x', 100)) AS wide FROM t2 GROUP BY 
c2) x " +
+          "ON t1.c2 = x.c2"
+
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"500",
+        lookThroughOperatorsKey -> "true") {
+        val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+        assert(findTopLevelShuffledHashJoin(adaptive).isEmpty,
+          "a large folded constant above the shuffle must keep the join as a 
sort merge join")
+        assert(findTopLevelSortMergeJoin(adaptive).size === 1)
+      }
+    }
+  }
+
+  test("SPARK-58084: Convert still fires when DemoteBroadcastHashJoin adds 
NO_BROADCAST_HASH") {
+    withTempView("t1", "t2") {
+      // t1 (the build candidate) has many empty partitions, so 
DemoteBroadcastHashJoin demotes

Review Comment:
   Minor: the comment attributes the `NO_BROADCAST_HASH` hint to t1, but 
`DemoteBroadcastHashJoin` only matches a direct `LogicalQueryStage` child - the 
t1 side is behind the aggregate, so the hint actually lands on the t2 side. The 
interaction is still exercised (a `NO_BROADCAST_HASH` hint is present and 
conversion fires), just worth fixing the attribution so the comment doesn't 
mislead.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/DemoteBroadcastHashJoin.scala:
##########
@@ -21,21 +21,15 @@ import org.apache.spark.MapOutputStatistics
 import org.apache.spark.sql.catalyst.optimizer.JoinSelectionHelper
 import org.apache.spark.sql.catalyst.planning.ExtractEquiJoinKeys
 import org.apache.spark.sql.catalyst.plans.{LeftAnti, LeftOuter, RightOuter}
-import org.apache.spark.sql.catalyst.plans.logical.{HintInfo, Join, 
JoinStrategyHint, LogicalPlan, NO_BROADCAST_HASH, PREFER_SHUFFLE_HASH, 
SHUFFLE_HASH}
+import org.apache.spark.sql.catalyst.plans.logical.{HintInfo, Join, 
JoinStrategyHint, LogicalPlan, NO_BROADCAST_HASH}
 import org.apache.spark.sql.catalyst.rules.Rule
-import org.apache.spark.sql.internal.SQLConf
 
 /**
- * This optimization rule includes three join selection:
- *   1. detects a join child that has a high ratio of empty partitions and 
adds a
- *      NO_BROADCAST_HASH hint to avoid it being broadcast, as shuffle join is 
faster in this case:
- *      many tasks complete immediately since one join side is empty.
- *   2. detects a join child that every partition size is less than local map 
threshold and adds a
- *      PREFER_SHUFFLE_HASH hint to encourage being shuffle hash join instead 
of sort merge join.
- *   3. if a join satisfies both NO_BROADCAST_HASH and PREFER_SHUFFLE_HASH,
- *      then add a SHUFFLE_HASH hint.
+ * This optimization rule detects a join child that has a high ratio of empty 
partitions and adds a
+ * NO_BROADCAST_HASH hint to avoid it being broadcast, as shuffle join is 
faster in this case: many
+ * tasks complete immediately since one join side is empty.
  */
-object DynamicJoinSelection extends Rule[LogicalPlan] with JoinSelectionHelper 
{
+object DemoteBroadcastHashJoin extends Rule[LogicalPlan] with 
JoinSelectionHelper {

Review Comment:
   The rename needs a `docs/sql-migration-guide.md` entry: existing 
`spark.sql.adaptive.optimizer.excludedRules` configs naming 
`org.apache.spark.sql.execution.adaptive.DynamicJoinSelection` now silently 
stop matching (unknown names are ignored, not errored), and users who 
previously disabled the shuffled-hash preference by excluding that rule must 
now set 
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled=false` 
instead. Worth one entry covering both the rename and where the SHJ-over-SMJ 
selection moved.



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