diff --git a/docs/sql-performance-tuning.md b/docs/sql-performance-tuning.md index bbb449360287..df6bb606cb8c 100644 --- a/docs/sql-performance-tuning.md +++ b/docs/sql-performance-tuning.md @@ -375,6 +375,30 @@ AQE converts sort-merge join to shuffled hash join when all post shuffle partiti 3.2.0 + + spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled + false + + When true, Spark converts a sort-merge join to a shuffled hash join during adaptive execution when the build side's materialized per-partition sizes are all within spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold (which additionally requires spark.sql.adaptive.advisoryPartitionSizeInBytes to not be larger than it), even when non-shuffle operators (such as aggregate, project, filter and window) sit between the join and its input shuffle. + + 4.3.0 + + + spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.minWideningFactor + 1.0 + + The lower bound applied to the row-widening factor used by spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled when bounding a build side's shuffled hash map size. The factor scales the input shuffle bytes by the estimated per-row size growth of the operators between the join and its shuffle; a larger lower bound is more conservative and makes the conversion less likely when statistics may under-estimate the build size. Must be positive. + + 4.3.0 + + + spark.sql.adaptive.costEvaluator.countLocalSort.enabled + (value of spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled) + + When true, the default AQE cost evaluator also counts the number of local sorts as a lower-priority tiebreaker below the number of shuffles, so that among plans with the same number of shuffles the one with fewer local sorts is preferred. For example, a sort-merge join is replaced by a shuffled hash join only when the conversion does not push extra sorts elsewhere in the plan. Defaults to the value of spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled, so it is enabled together with that conversion. + + 4.3.0 + ### Optimizing Skew Join diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala index 66c4a39ce823..71f30ca49d86 100755 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala @@ -1288,7 +1288,7 @@ case class FindInSet(left: Expression, right: Expression) extends BinaryExpressi trait String2TrimExpression extends Expression with ImplicitCastInputTypes { - protected def srcStr: Expression + private[sql] def srcStr: Expression protected def trimStr: Option[Expression] protected def direction: String diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala index 13e3cb76805d..833a0e80ab02 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala @@ -20,8 +20,10 @@ package org.apache.spark.sql.catalyst.optimizer import scala.annotation.tailrec import scala.util.control.NonFatal +import org.apache.spark.MapOutputStatistics import org.apache.spark.internal.Logging import org.apache.spark.internal.LogKeys.{HASH_JOIN_KEYS, JOIN_CONDITION} +import org.apache.spark.sql.catalyst.SQLConfHelper import org.apache.spark.sql.catalyst.expressions._ import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression import org.apache.spark.sql.catalyst.planning.{ExtractEquiJoinKeys, ExtractFiltersAndInnerJoins, ExtractSingleColumnNullAwareAntiJoin} @@ -287,7 +289,18 @@ case object BuildRight extends BuildSide case object BuildLeft extends BuildSide -trait JoinSelectionHelper extends Logging { +trait JoinSelectionHelper extends SQLConfHelper with Logging { + + def preferShuffledHashJoin( + mapStats: MapOutputStatistics, + sizeInBytesFactor: Double = 1.0): 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) + } def getBroadcastBuildSide( join: Join, 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 6fe7a957a121..8f44a72b4dec 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 @@ -1337,6 +1337,46 @@ object SQLConf { .bytesConf(ByteUnit.BYTE) .createWithDefault(0L) + val ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED = + buildConf("spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled") + .doc("When true, Spark converts a sort merge join to a shuffled hash join during adaptive " + + "execution when the build side's materialized per-partition sizes are all within " + + s"${ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key} (which additionally requires " + + s"${ADVISORY_PARTITION_SIZE_IN_BYTES.key} to not be larger than it), even when " + + "non-shuffle operators sit between the join and its input shuffle.") + .version("4.3.0") + .withBindingPolicy(ConfigBindingPolicy.SESSION) + .booleanConf + .createWithDefault(false) + + val ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_MIN_WIDENING_FACTOR = + buildConf("spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.minWideningFactor") + .doc("The lower bound applied to the row-widening factor used by " + + s"${ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key} when bounding a " + + "build side's shuffled hash map size. The factor scales the input shuffle bytes by the " + + "estimated per-row size growth of the operators between the join and its shuffle; a " + + "larger lower bound is more conservative and makes the conversion less likely when " + + "statistics may under-estimate the build size. Must be positive.") + .version("4.3.0") + .withBindingPolicy(ConfigBindingPolicy.SESSION) + .doubleConf + .checkValue(_ > 0, "The minimum widening factor must be positive.") + .createWithDefault(1.0) + + val ADAPTIVE_COST_EVALUATOR_COUNT_LOCAL_SORT_ENABLED = + buildConf("spark.sql.adaptive.costEvaluator.countLocalSort.enabled") + .doc("When true, the default AQE cost evaluator also counts the number of local sorts as a " + + "lower-priority tiebreaker below the number of shuffles. This lets adaptive execution " + + "prefer a plan with fewer local sorts among plans with the same number of shuffles, for " + + s"example a shuffled hash join produced by " + + s"${ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key} over a " + + "sort merge join when the conversion does not push extra sorts elsewhere. Defaults to " + + s"${ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key} so that it is " + + "enabled together with that conversion.") + .version("4.3.0") + .withBindingPolicy(ConfigBindingPolicy.SESSION) + .fallbackConf(ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED) + val ADAPTIVE_OPTIMIZE_SKEWS_IN_REBALANCE_PARTITIONS_ENABLED = buildConf("spark.sql.adaptive.optimizeSkewsInRebalancePartitions.enabled") .doc(s"When true and '${ADAPTIVE_EXECUTION_ENABLED.key}' is true, Spark will optimize the " + @@ -8090,6 +8130,15 @@ class SQLConf extends Serializable with Logging with SqlApiConf { def nonEmptyPartitionRatioForBroadcastJoin: Double = getConf(NON_EMPTY_PARTITION_RATIO_FOR_BROADCAST_JOIN) + def convertSortMergeJoinToShuffledHashJoinEnabled: Boolean = + getConf(ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED) + + def convertSortMergeJoinToShuffledHashJoinMinWideningFactor: Double = + getConf(ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_MIN_WIDENING_FACTOR) + + def costEvaluatorCountLocalSortEnabled: Boolean = + getConf(ADAPTIVE_COST_EVALUATOR_COUNT_LOCAL_SORT_ENABLED) + def coalesceShufflePartitionsEnabled: Boolean = getConf(COALESCE_PARTITIONS_ENABLED) def minBatchesToRetain: Int = getConf(MIN_BATCHES_TO_RETAIN) 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 9a483076ff56..7040ab51cf51 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 @@ -102,7 +102,9 @@ case class AdaptiveSparkPlanExec( conf.getConf(SQLConf.ADAPTIVE_CUSTOM_COST_EVALUATOR_CLASS) match { case Some(className) => CostEvaluator.instantiate(className, context.session.sparkContext.getConf) - case _ => SimpleCostEvaluator(conf.getConf(SQLConf.ADAPTIVE_FORCE_OPTIMIZE_SKEWED_JOIN)) + case _ => SimpleCostEvaluator( + conf.getConf(SQLConf.ADAPTIVE_FORCE_OPTIMIZE_SKEWED_JOIN), + conf.costEvaluatorCountLocalSortEnabled) } // A list of physical plan rules to be applied before creation of query stages. The physical @@ -125,6 +127,10 @@ case class AdaptiveSparkPlanExec( InsertSortForLimitAndOffset, AdjustShuffleExchangePosition, ValidateSparkPlan, + // Must run before `ReplaceHashWithSortAgg`: converting a sort merge join to a shuffled hash + // join drops its child ordering, which `ReplaceHashWithSortAgg` would otherwise rely on to + // turn a hash aggregate into a sort aggregate. + ConvertSortMergeJoinToShuffledHashJoin(ensureRequirements), ReplaceHashWithSortAgg, RemoveRedundantSorts, RemoveRedundantWindowGroupLimits, diff --git a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/ConvertSortMergeJoinToShuffledHashJoin.scala b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/ConvertSortMergeJoinToShuffledHashJoin.scala new file mode 100644 index 000000000000..ea6095638c76 --- /dev/null +++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/ConvertSortMergeJoinToShuffledHashJoin.scala @@ -0,0 +1,229 @@ +/* + * 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.sql.catalyst.expressions.{Alias, Attribute, CaseWhen, Cast, Coalesce, Expression, If, Literal, Lower, String2TrimExpression, Substring, UnsafeRow, Upper} +import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight, BuildSide, JoinSelectionHelper} +import org.apache.spark.sql.catalyst.plans.LeftExistence +import org.apache.spark.sql.catalyst.plans.logical.Join +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} + +/** + * 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. + * Unlike [[DynamicJoinSelection]], this runs on the physical plan, so it can reach the input + * shuffle through operators (aggregate, project, filter, window, etc...) sitting above it. + * + * 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. Two guards keep that estimate + * a valid bound (see [[ExtractShuffleStage]] and [[selectBuildSide]]): + * - 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 { + + /** + * Chooses the build side for the shuffled hash join. A side is eligible only if it is allowed + * as a build side for this join type and its (widening-adjusted) input shuffle is small enough + * to build a local hash map. When both sides are eligible, the smaller one (by widening-adjusted + * total shuffle bytes) is chosen. + */ + private def selectBuildSide( + smj: SortMergeJoinExec, + left: ShuffleQueryStageExec, + right: ShuffleQueryStageExec): Option[BuildSide] = { + val leftFactor = wideningFactor(smj.left.output, left.output) + val rightFactor = wideningFactor(smj.right.output, right.output) + val canBuildLeft = canBuildShuffledHashJoinLeft(smj.joinType) && + preferShuffledHashJoin(left.mapStats.get, leftFactor) + val canBuildRight = canBuildShuffledHashJoinRight(smj.joinType) && + preferShuffledHashJoin(right.mapStats.get, rightFactor) + if (canBuildLeft && canBuildRight) { + val leftSize = left.mapStats.get.bytesByPartitionId.sum * leftFactor + val rightSize = right.mapStats.get.bytesByPartitionId.sum * rightFactor + if (leftSize < rightSize) Some(BuildLeft) else Some(BuildRight) + } else if (canBuildLeft) { + Some(BuildLeft) + } else if (canBuildRight) { + Some(BuildRight) + } else { + None + } + } + + /** + * 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 hasJoinStrategyHint(smj: SortMergeJoinExec): Boolean = smj.logicalLink.exists { + case j: Join => + j.hint.leftHint.exists(_.strategy.isDefined) || j.hint.rightHint.exists(_.strategy.isDefined) + case _ => false + } + + override def apply(plan: SparkPlan): SparkPlan = { + if (!conf.convertSortMergeJoinToShuffledHashJoinEnabled) { + return plan + } + val optimizedPlan = plan.transformUp { + case smj @ SortMergeJoinExec(leftKeys, rightKeys, joinType, condition, + ExtractShuffleStage(left), ExtractShuffleStage(right), false) + // Do not convert if the join keys are not hash-join-compatible (e.g. collated or other + // non-binary-stable string keys), since a hash join matches keys by binary equality and + // would return wrong results. This mirrors the guard on the other SHJ-planning paths. + if !hasJoinStrategyHint(smj) && hashJoinSupported(leftKeys, rightKeys) => + selectBuildSide(smj, left, right) match { + case Some(buildSide) => + ShuffledHashJoinExec(leftKeys, rightKeys, joinType, buildSide, condition, + stripSort(smj.left), stripSort(smj.right)) + case None => smj + } + } + if (optimizedPlan.fastEquals(plan)) { + plan + } else { + // A shuffled hash join does not preserve the sort merge join's output ordering. Re-run + // EnsureRequirements so any ordering an ancestor still needs is re-established, keeping the + // plan valid. AQE's CostEvaluator then decides between this plan and the current one. + ensureRequirements.apply(optimizedPlan) + } + } + + /** + * Drops a top-level [[SortExec]] since a shuffled hash join does not require sorted input; + * [[RemoveRedundantSorts]] cleans up any remaining redundant sorts afterwards. + */ + private def stripSort(plan: SparkPlan): SparkPlan = plan match { + case s: SortExec if !s.global => s.child + case other => other + } + + /** + * Finds a join child's input shuffle, looking through the [[SortExec]] and other non-shuffle + * operators (aggregate, project, filter, window, left-existence join) above it. Descent stops at + * the first [[ShuffleQueryStageExec]], which is thus guaranteed to be the join's own input + * shuffle whose statistics bound (or, for a reducing aggregate, upper-bound) the build side. The + * stage must be materialized with stats and originate from [[EnsureRequirements]], so swapping + * the join type does not change the shuffle. + * + * A [[ProjectExec]], [[BaseAggregateExec]] or [[WindowExecBase]] is only traversed when all of + * its output expressions are size-bounded (see [[isSizeBoundedExpr]]); otherwise the shuffle + * bytes could badly under-estimate the non-spillable hash-map build size (e.g. + * `repeat(max(c2), 10000)` above a small shuffle), so descent stops and the join is left as is. + */ + object ExtractShuffleStage { + def unapply(plan: SparkPlan): Option[ShuffleQueryStageExec] = findShuffleStage(plan) + + @tailrec + private def findShuffleStage(plan: SparkPlan): Option[ShuffleQueryStageExec] = plan match { + case s: ShuffleQueryStageExec if s.isMaterialized && s.mapStats.isDefined && + s.shuffle.shuffleOrigin == ENSURE_REQUIREMENTS => Some(s) + case _: FilterExec | _: SortExec | _: WindowGroupLimitExec | _: CollectMetricsExec => + findShuffleStage(plan.children.head) + case p: ProjectExec if p.projectList.forall(isSizeBoundedExpr) => + findShuffleStage(p.child) + case a: BaseAggregateExec if a.resultExpressions.forall(isSizeBoundedExpr) => + findShuffleStage(a.child) + case w: WindowExecBase if w.windowExpression.forall(isSizeBoundedExpr) => + findShuffleStage(w.child) + case join: BaseJoinExec => + join.joinType match { + case LeftExistence(_) => findShuffleStage(join.left) + case _ => None + } + case _ => None + } + } + + /** + * Whether `expr`'s result byte-size is bounded by the values it reads, so it cannot widen a row. + * An operator all of whose outputs are size-bounded keeps the input shuffle bytes a valid bound + * on the non-spillable hash-map build size; an unbounded output (e.g. `repeat` or `concat`, which + * synthesize a wider value) makes the shuffle bytes an under-estimate and stops the traversal. + * + * An [[Attribute]] is always bounded: it refers to a value produced by a descendant operator. + * The traversal checks every operator down to the input shuffle, so if a descendant synthesized a + * wide value (e.g. a lower `ProjectExec` with `repeat(...)`) this rule stops there; by induction + * any attribute that survives is grounded in the shuffle output. Note that aggregate functions do + * not appear inline here - a physical aggregate exposes them as result attributes - so an + * aggregate result (`max`, and equally an accumulating `collect_list` whose bytes are already in + * the shuffle below) is bounded through this same [[Attribute]] case. + * + * A fixed-width result ([[UnsafeRow.isFixedLength]], stored in an 8-byte word) is bounded + * regardless of inputs. Beyond that, only a whitelist of length-non-increasing transforms over + * bounded children is accepted; anything else (e.g. `repeat`, `concat`, arithmetic on strings) is + * treated as potentially widening. + */ + private def isSizeBoundedExpr(expr: Expression): Boolean = { + if (UnsafeRow.isFixedLength(expr.dataType)) { + return true + } + expr match { + case _: Attribute | _: Literal => true + case e: Alias => isSizeBoundedExpr(e.child) + // The Cast is a very common expression, it may slightly increase the size in bytes + // but should be tolerated. + case e: Cast => isSizeBoundedExpr(e.child) + case e: Upper => isSizeBoundedExpr(e.child) + case e: Lower => isSizeBoundedExpr(e.child) + case e: Substring => isSizeBoundedExpr(e.str) + case e: String2TrimExpression => isSizeBoundedExpr(e.srcStr) + // Conditionals only pick one of their (bounded) branch values. + case If(_, t, f) => isSizeBoundedExpr(t) && isSizeBoundedExpr(f) + case CaseWhen(branches, elseValue) => + branches.forall(b => isSizeBoundedExpr(b._2)) && elseValue.forall(isSizeBoundedExpr) + case Coalesce(children) => children.forall(isSizeBoundedExpr) + case _ => false + } + } +} diff --git a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/DynamicJoinSelection.scala b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/DynamicJoinSelection.scala index 217569ae645c..67a5d2954bdf 100644 --- a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/DynamicJoinSelection.scala +++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/DynamicJoinSelection.scala @@ -23,7 +23,6 @@ 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.rules.Rule -import org.apache.spark.sql.internal.SQLConf /** * This optimization rule includes three join selection: @@ -44,14 +43,6 @@ object DynamicJoinSelection extends Rule[LogicalPlan] with JoinSelectionHelper { (nonZeroCnt * 1.0 / partitionCnt) < conf.nonEmptyPartitionRatioForBroadcastJoin } - private def preferShuffledHashJoin(mapStats: MapOutputStatistics): 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(_ <= maxShuffledHashJoinLocalMapThreshold) - } - private def selectJoinStrategy( join: Join, isLeft: Boolean): Option[JoinStrategyHint] = { diff --git a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/simpleCosting.scala b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/simpleCosting.scala index 28b757114ebe..84da42ff68a6 100644 --- a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/simpleCosting.scala +++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/simpleCosting.scala @@ -18,18 +18,33 @@ package org.apache.spark.sql.execution.adaptive import org.apache.spark.sql.errors.QueryExecutionErrors -import org.apache.spark.sql.execution.SparkPlan +import org.apache.spark.sql.execution.{SortExec, SparkPlan} import org.apache.spark.sql.execution.exchange.ShuffleExchangeLike import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, ShuffledJoin} /** - * A simple implementation of [[Cost]], which takes a number of [[Long]] as the cost value. + * A simple implementation of [[Cost]] produced by [[SimpleCostEvaluator]]. Its three components are + * compared lexicographically in priority order: + * 1. `numSkewJoins`: more skew joins means lower cost, so it is compared descending and first; + * 2. `numShuffles`: fewer shuffles means lower cost; + * 3. `numLocalSorts`: the lowest-priority tiebreaker, so among plans with the same number of skew + * joins and shuffles the one with fewer local sorts is preferred (e.g. a shuffled hash join + * over a sort merge join when the conversion does not push extra sorts elsewhere). + * + * `numSkewJoins` and `numLocalSorts` are `0` when the corresponding feature is disabled in the + * evaluator, so they do not affect the comparison in that case. */ -case class SimpleCost(value: Long) extends Cost { +case class SimpleCost(numSkewJoins: Int, numShuffles: Int, numLocalSorts: Int) extends Cost { override def compare(that: Cost): Int = that match { - case SimpleCost(thatValue) => - if (value < thatValue) -1 else if (value > thatValue) 1 else 0 + case SimpleCost(thatSkewJoins, thatShuffles, thatLocalSorts) => + val bySkewJoins = Integer.compare(thatSkewJoins, numSkewJoins) + if (bySkewJoins != 0) { + bySkewJoins + } else { + val byShuffles = Integer.compare(numShuffles, thatShuffles) + if (byShuffles != 0) byShuffles else Integer.compare(numLocalSorts, thatLocalSorts) + } case _ => throw QueryExecutionErrors.cannotCompareCostWithTargetCostError(that.toString) } @@ -37,24 +52,23 @@ case class SimpleCost(value: Long) extends Cost { /** * A skew join aware implementation of [[CostEvaluator]], which counts the number of - * [[ShuffleExchangeLike]] nodes and skew join nodes in the plan. + * [[ShuffleExchangeLike]] nodes, skew join nodes and (optionally) local [[SortExec]] nodes in the + * plan. See [[SimpleCost]] for how the components are compared. */ -case class SimpleCostEvaluator(forceOptimizeSkewedJoin: Boolean) extends CostEvaluator { - override def evaluateCost(plan: SparkPlan): Cost = { - val numShuffles = plan.collect { - case s: ShuffleExchangeLike => s - }.size +case class SimpleCostEvaluator(forceOptimizeSkewedJoin: Boolean, countLocalSort: Boolean) + extends CostEvaluator { - if (forceOptimizeSkewedJoin) { - val numSkewJoins = plan.collect { - case j: ShuffledJoin if j.isSkewJoin => j - case j: BroadcastHashJoinExec if j.isSkewJoin => j - }.size - // We put `-numSkewJoins` in the first 32 bits of the long value, so that it's compared first - // when comparing the cost, and larger `numSkewJoins` means lower cost. - SimpleCost(-numSkewJoins.toLong << 32 | numShuffles) - } else { - SimpleCost(numShuffles) + override def evaluateCost(plan: SparkPlan): Cost = { + var numSkewJoins = 0 + var numShuffles = 0 + var numLocalSorts = 0 + plan.foreach { + case j: ShuffledJoin if forceOptimizeSkewedJoin && j.isSkewJoin => numSkewJoins += 1 + case j: BroadcastHashJoinExec if forceOptimizeSkewedJoin && j.isSkewJoin => numSkewJoins += 1 + case _: ShuffleExchangeLike => numShuffles += 1 + case s: SortExec if countLocalSort && !s.global => numLocalSorts += 1 + case _ => } + SimpleCost(numSkewJoins, numShuffles, numLocalSorts) } } 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 8cf6fbf921da..357a5937e423 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 @@ -29,11 +29,11 @@ import org.apache.spark.scheduler.{SparkListener, SparkListenerEvent, SparkListe import org.apache.spark.shuffle.sort.SortShuffleManager import org.apache.spark.sql.{DataFrame, Dataset, Row, SparkSession} import org.apache.spark.sql.catalyst.InternalRow -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, EqualTo, IsNull, Or} +import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, AttributeReference, EqualTo, IsNull, Or, SortOrder} import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight} import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti} import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Join, JoinHint, LocalRelation, LogicalPlan} -import org.apache.spark.sql.catalyst.plans.physical.CoalescedNullAwareHashPartitioning +import org.apache.spark.sql.catalyst.plans.physical.{CoalescedNullAwareHashPartitioning, SinglePartition} import org.apache.spark.sql.classic.Strategy import org.apache.spark.sql.execution._ import org.apache.spark.sql.execution.aggregate.BaseAggregateExec @@ -2461,6 +2461,358 @@ class AdaptiveQueryExecSuite } } + test("SPARK-58084: Replace 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 DynamicJoinSelection's hint path does not fire. The new + // physical rule looks through those operators to the materialized shuffle stage. + 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 => + // Enabled (default): the sort merge join is converted to a shuffled hash join. + withSQLConf( + SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key -> "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) + } + // Disabled: the join stays a sort merge join (previous behavior). + withSQLConf( + SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key -> "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.ADAPTIVE_OPTIMIZER_EXCLUDED_RULES.key -> + "org.apache.spark.sql.execution.adaptive.DynamicJoinSelection", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100", + SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> "500", + SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key -> "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.ADAPTIVE_OPTIMIZER_EXCLUDED_RULES.key -> + "org.apache.spark.sql.execution.adaptive.DynamicJoinSelection", + 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) + 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.ADAPTIVE_OPTIMIZER_EXCLUDED_RULES.key -> + "org.apache.spark.sql.execution.adaptive.DynamicJoinSelection", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100", + SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> "500", + SQLConf.ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key -> "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: Replace 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, but the outer sort merge join + // 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 small1.c1 FROM small1 JOIN small2 ON small1.c1 = small2.c1 " + + "JOIN big ON small1.c1 = big.c1" + + // Exclude DynamicJoinSelection so only the new physical rule can convert, isolating it. + withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "3", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1", + SQLConf.ADAPTIVE_OPTIMIZER_EXCLUDED_RULES.key -> + "org.apache.spark.sql.execution.adaptive.DynamicJoinSelection", + 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 20).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 and exclude DynamicJoinSelection so only the new rule can convert. + 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.ADAPTIVE_OPTIMIZER_EXCLUDED_RULES.key -> + "org.apache.spark.sql.execution.adaptive.DynamicJoinSelection", + 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") { + // 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, matching + // DynamicJoinSelection which never overrides an existing 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-35650: Coalesce number of partitions by AEQ") { withSQLConf(SQLConf.COALESCE_PARTITIONS_MIN_PARTITION_NUM.key -> "1") { Seq("REPARTITION", "REBALANCE(key)") @@ -3709,6 +4061,16 @@ class AdaptiveQueryExecSuite } } +/** + * A minimal leaf plan with a single output attribute, used to build tiny plans for cost tests. + */ +private case class CostTestLeafExec() extends LeafExecNode { + override protected def doExecute(): RDD[InternalRow] = + throw SparkException.internalError("should not be executed") + override def output: Seq[Attribute] = + AttributeReference("a", org.apache.spark.sql.types.IntegerType)() :: Nil +} + /** * Invalid implementation class for [[CostEvaluator]]. */ @@ -3723,7 +4085,7 @@ private case class SimpleShuffleSortCostEvaluator() extends CostEvaluator { case s: ShuffleExchangeLike => s case s: SortExec => s }.size - SimpleCost(cost) + SimpleCost(numSkewJoins = 0, numShuffles = cost, numLocalSorts = 0) } }