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 e5d87887d825 [SPARK-58084][SQL] Support converting sort merge join to
shuffled hash join in AQE physical plan
e5d87887d825 is described below
commit e5d87887d8251a43b4fc823d21dd7828ea3bcd8e
Author: Xiduo You <[email protected]>
AuthorDate: Wed Jul 15 14:48:39 2026 +0800
[SPARK-58084][SQL] Support converting sort merge join to shuffled hash join
in AQE physical plan
### What changes were proposed in this pull request?
This PR lets adaptive query execution (AQE) convert a `SortMergeJoinExec`
into a `ShuffledHashJoinExec` on the **physical plan**, so the conversion can
see through non-shuffle operators (aggregate, project, filter, window,
left-existence join) that sit between the join and its input shuffle.
1. **New rule `ConvertSortMergeJoinToShuffledHashJoin`** (in
`queryStagePreparationRules`, **before** `ReplaceHashWithSortAgg` so the
conversion does not destroy an ordering that rule relies on). Once the join's
input shuffles have materialized, it reaches each side's
`ShuffleQueryStageExec` through a set of operators above it and, if a build
side fits `spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold`, rewrites
the join to a shuffled hash join. Guards:
- **binary-stable keys** - skipped unless `hashJoinSupported(leftKeys,
rightKeys)`, so collated / non-binary-stable string keys stay a sort merge join
(matching `JoinSelection`);
- **explicit strategy hint** - skipped when the join carries any
left/right strategy hint (e.g. `MERGE`), so a user hint is never overridden;
- **non-spillable build-size bound** - the traversal only looks through
an operator whose outputs are all size-bounded (`isSizeBoundedExpr`:
attributes, fixed-width values, `cast`, and length-non-increasing string
transforms; not `repeat`/`concat`), and the build-side estimate is scaled by a
per-row widening factor floored at
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.minWideningFactor`
(default `1.0`). This prevents a row-widening operator from turning a spillable
[...]
The swap is shuffle-free (both are `ShuffledJoin` with the same
distribution/partitioning); only the child sorts become unnecessary. Because a
shuffled hash join loses the sort merge join's output ordering,
`EnsureRequirements` is re-run so any ordering an ancestor still needs is
re-established. Gated by
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` (default
`true`).
2. **The logical SMJ->SHJ preference is removed in favor of the new
physical rule.** `DynamicJoinSelection` is renamed to `DemoteBroadcastHashJoin`
and now only does what its name says - demotes a broadcast to a shuffle join
(`NO_BROADCAST_HASH`) for a child with many empty partitions. Its old
`PREFER_SHUFFLE_HASH` / `SHUFFLE_HASH` selection is dropped, and with it:
- the internal `PREFER_SHUFFLE_HASH` join-strategy hint (`hints.scala`)
and the `hintToPreferShuffleHashJoin*` helpers in `JoinSelectionHelper`, plus
their use in `SparkStrategies.checkHintNonEquiJoin`;
- the AQE optimizer batch is renamed accordingly (`Demote Broadcast Hash
Join`).
This consolidates all SMJ->SHJ selection into
`ConvertSortMergeJoinToShuffledHashJoin` on the physical plan. Note for tests /
callers that previously pinned a plan by excluding `DynamicJoinSelection` from
`spark.sql.adaptive.optimizer.excludedRules`: that no longer disables the SHJ
conversion - disable
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` instead.
3. **New helper `Literal.valueSizeInBytes`** returns a safe upper bound
(never an under-estimate) of a literal value's size in bytes, or `None` when it
cannot be determined reliably: fixed-length and null values report
`defaultSize`; string / binary report their real payload; arrays / maps /
structs sum their measurable elements; anything else (e.g. variant) returns
`None`. `isSizeBoundedExpr` uses it so a literal wider than its type's
`defaultSize` (and any unmeasurable literal) is t [...]
4. **`SimpleCost` now holds `(numSkewJoins, numShuffles, numLocalSorts)`
compared lexicographically** (replacing the packed-`Long` bit tricks).
Local-sort counting is controlled by
`spark.sql.adaptive.costEvaluator.countLocalSort.enabled`, which **falls back
to** the `lookThroughOperators.enabled` config, so it is enabled together with
it and is a no-op otherwise.
Class hierarchy note: `SortMergeJoinExec` and `ShuffledHashJoinExec` both
extend `ShuffledJoin`, which is why the swap needs no new shuffle.
`ShuffledHashJoinExec` (via `HashJoin.outputOrdering`) only preserves the
streamed side's ordering, whereas an inner `SortMergeJoinExec` keeps both
sides'; the local-sort cost handles cases where that difference forces a new
sort upstream.
### Why are the changes needed?
Previously `DynamicJoinSelection` (now `DemoteBroadcastHashJoin`) preferred
a shuffled hash join over a sort merge join, but it worked on the **logical**
plan and only fired when the join child was a shuffle stage directly. When
non-inflating operators (an aggregate, or a filter from a `HAVING`) sit between
the join and its input shuffle, the hint was never added and the join stayed a
sort merge join even though a shuffled hash join would be cheaper. Doing the
selection on the physica [...]
### Does this PR introduce _any_ user-facing change?
Yes, four new configurations, all documented in
`docs/sql-performance-tuning.md`:
- `spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled`
(default `true`)
-
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.lookThroughOperators.enabled`
(default `false`)
-
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.minWideningFactor`
(default `1.0`)
- `spark.sql.adaptive.costEvaluator.countLocalSort.enabled` (defaults to
the value of the lookThroughOperators.enabled config)
All default to off / no-op, the direct-shuffle conversion reproduces the
old default behavior (inert unless maxShuffledHashJoinLocalMapThreshold is
raised) so there is no behavior change unless a user opts in.
### How was this patch tested?
New unit tests in `AdaptiveQueryExecSuite`:
- Converting a sort merge join to a shuffled hash join when an aggregate /
`HAVING` filter sits above the shuffle.
- The converted plan stays valid when an ancestor needs the join's ordering
(`EnsureRequirements` re-adds the sort).
- Conversion is looked through size-bounded operators
(`max`/`cast`/`substring`) but stopped by a row-widening operator
(`repeat(max(c2), n)`).
- `minWideningFactor` makes the size bound more conservative and rejects an
otherwise-eligible conversion.
- Collated (non-binary-stable) join keys and an explicit `MERGE` hint keep
the join as a sort merge join with the config enabled.
- `SimpleCostEvaluator` orders plans by skew joins, then shuffles, then
local sorts; a conversion that would add a local sort elsewhere is rejected
when local-sort counting is on.
New unit tests in `LiteralExpressionSuite` cover `Literal.valueSizeInBytes`
across fixed-length, null, string / binary (including multi-byte UTF-8), array,
map, struct, and unmeasurable (variant) values.
Full `AdaptiveQueryExecSuite` passes (124 tests).
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
Closes #57181 from ulysses-you/SPARK-convert-smj-to-shj-aqe.
Authored-by: Xiduo You <[email protected]>
Signed-off-by: Xiduo You <[email protected]>
(cherry picked from commit 3f123cc7620867165cff0356eadf6ba07be46975)
Signed-off-by: Xiduo You <[email protected]>
---
docs/sql-migration-guide.md | 1 +
docs/sql-performance-tuning.md | 32 ++
.../spark/sql/catalyst/expressions/literals.scala | 60 +++
.../catalyst/expressions/stringExpressions.scala | 2 +-
.../spark/sql/catalyst/optimizer/joins.scala | 24 +-
.../spark/sql/catalyst/plans/logical/hints.scala | 8 -
.../org/apache/spark/sql/internal/SQLConf.scala | 71 ++++
.../expressions/LiteralExpressionSuite.scala | 50 ++-
.../spark/sql/execution/SparkStrategies.scala | 3 +-
.../sql/execution/adaptive/AQEOptimizer.scala | 2 +-
.../execution/adaptive/AdaptiveSparkPlanExec.scala | 8 +-
.../ConvertSortMergeJoinToShuffledHashJoin.scala | 260 ++++++++++++
...lection.scala => DemoteBroadcastHashJoin.scala} | 31 +-
.../sql/execution/adaptive/simpleCosting.scala | 56 ++-
.../QueryPlanningTrackerEndToEndSuite.scala | 6 +-
.../adaptive/AdaptiveQueryExecSuite.scala | 462 ++++++++++++++++++++-
16 files changed, 990 insertions(+), 86 deletions(-)
diff --git a/docs/sql-migration-guide.md b/docs/sql-migration-guide.md
index 91fb7a52f670..9b119a2b8c83 100644
--- a/docs/sql-migration-guide.md
+++ b/docs/sql-migration-guide.md
@@ -27,6 +27,7 @@ license: |
- Since Spark 4.3, zero-length files are skipped during Parquet schema
inference instead of failing with a `FAILED_READ_FILE.CANNOT_READ_FILE_FOOTER`
error.
- Since Spark 4.3, the configuration key
`spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys.enabled` has
been renamed to
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` to
reflect that it now applies to storage-partitioned joins, aggregates, and
windows. The old key continues to work as an alias.
- Since Spark 4.3, the Spark Thrift Server rejects setting JVM system
properties through the `set:system:` session configuration overlay (for
example, in a JDBC connection string). To restore the previous behavior, set
`spark.sql.legacy.hive.thriftServer.allowSettingSystemProperties` to `true`.
+- Since Spark 4.3, the adaptive execution rule
`org.apache.spark.sql.execution.adaptive.DynamicJoinSelection` has been renamed
to `DemoteBroadcastHashJoin`, which now only demotes broadcast hash joins
(emitting `NO_BROADCAST_HASH`). Its selection of shuffled hash join over sort
merge join has moved to a new physical rule gated by
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` (default
`true`). If you previously disabled the shuffled-hash-join preference by
listing `o [...]
## Upgrading from Spark SQL 4.1 to 4.2
diff --git a/docs/sql-performance-tuning.md b/docs/sql-performance-tuning.md
index 0294f05641d9..c86570ff34a1 100644
--- a/docs/sql-performance-tuning.md
+++ b/docs/sql-performance-tuning.md
@@ -380,6 +380,38 @@ AQE converts sort-merge join to shuffled hash join when
all post shuffle partiti
</td>
<td>3.2.0</td>
</tr>
+ <tr>
+
<td><code>spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled</code></td>
+ <td>true</td>
+ <td>
+ 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
<code>spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold</code> (which
additionally requires
<code>spark.sql.adaptive.advisoryPartitionSizeInBytes</code> to not be larger
than it). This is the master switch for the conversion. By default it only
looks through the join's own required sort to reach a direct input s [...]
+ </td>
+ <td>4.3.0</td>
+ </tr>
+ <tr>
+
<td><code>spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.lookThroughOperators.enabled</code></td>
+ <td>false</td>
+ <td>
+ When true, the sort-merge join to shuffled hash join conversion
additionally looks through non-shuffle operators (such as aggregate, project,
filter and window) sitting between the join and its input shuffle, instead of
only the join's own required sort. Has no effect unless
<code>spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled</code>
is true.
+ </td>
+ <td>4.3.0</td>
+ </tr>
+ <tr>
+
<td><code>spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.minWideningFactor</code></td>
+ <td>1.0</td>
+ <td>
+ The lower bound applied to the row-widening factor used when the
sort-merge-join to shuffled-hash-join conversion bounds 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.
+ </td>
+ <td>4.3.0</td>
+ </tr>
+ <tr>
+
<td><code>spark.sql.adaptive.costEvaluator.countLocalSort.enabled</code></td>
+ <td>(value of
<code>spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.lookThroughOperators.enabled</code>)</td>
+ <td>
+ 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
<code>spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.lookThroughOpe
[...]
+ </td>
+ <td>4.3.0</td>
+ </tr>
</table>
### Optimizing Skew Join
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
index cea16999c437..546d82546d03 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/literals.scala
@@ -300,6 +300,60 @@ object Literal {
s"but class ${Utils.getSimpleName(value.getClass)} found.")
}
+ /**
+ * The size in bytes of a literal `value` of the given `dataType`, or `None`
when it cannot be
+ * determined reliably. The result is a safe upper bound, never an
under-estimate, so callers can
+ * use it to bound memory:
+ * - a fixed-length type reports its `defaultSize` (exact);
+ * - a null `value` reports the type's `defaultSize`;
+ * - a variable-length type whose real payload size is known (string /
binary, or an array /
+ * map / struct all of whose elements are measurable) reports that real
size;
+ * - any other type (e.g. variant) returns `None`.
+ */
+ private[expressions] def valueSizeInBytes(value: Any, dataType: DataType):
Option[Int] = {
+ if (value == null || UnsafeRow.isFixedLength(dataType)) {
+ return Some(dataType.defaultSize)
+ }
+ PhysicalDataType(dataType) match {
+ case _: PhysicalCalendarIntervalType =>
Some(CalendarIntervalType.defaultSize)
+ case _: PhysicalStringType =>
Some(value.asInstanceOf[UTF8String].numBytes())
+ case PhysicalBinaryType => Some(value.asInstanceOf[Array[Byte]].length)
+ case _: PhysicalBinaryViewType =>
Some(value.asInstanceOf[BinaryView].numBytes())
+ case PhysicalArrayType(et, _) =>
+ val array = value.asInstanceOf[ArrayData]
+ var size = 0
+ var i = 0
+ while (i < array.numElements()) {
+ valueSizeInBytes(array.get(i, et), et) match {
+ case Some(elementSize) => size += elementSize
+ case None => return None
+ }
+ i += 1
+ }
+ Some(size)
+ case PhysicalMapType(kt, vt, _) =>
+ val map = value.asInstanceOf[MapData]
+ for {
+ keySize <- valueSizeInBytes(map.keyArray(), ArrayType(kt))
+ valueSize <- valueSizeInBytes(map.valueArray(), ArrayType(vt))
+ } yield keySize + valueSize
+ case st: PhysicalStructType =>
+ val row = value.asInstanceOf[InternalRow]
+ var size = 0
+ var i = 0
+ while (i < st.fields.length) {
+ val fieldType = st.fields(i).dataType
+ valueSizeInBytes(if (row.isNullAt(i)) null else row.get(i,
fieldType), fieldType) match {
+ case Some(fieldSize) => size += fieldSize
+ case None => return None
+ }
+ i += 1
+ }
+ Some(size)
+ case _ => None
+ }
+ }
+
/**
* Inverse of [[Literal.sql]]
*/
@@ -450,6 +504,12 @@ case class Literal (value: Any, dataType: DataType)
extends LeafExpression {
override def nullable: Boolean = value == null
+ /**
+ * The size in bytes of this literal's value as a safe upper bound, or
`None` when it cannot be
+ * determined reliably. See [[Literal.valueSizeInBytes]] for the exact
contract.
+ */
+ lazy val valueSizeInBytes: Option[Int] = Literal.valueSizeInBytes(value,
dataType)
+
private def timeZoneId =
DateTimeUtils.getZoneId(SQLConf.get.sessionLocalTimeZone)
override lazy val treePatternBits: BitSet = {
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..edd63829a711 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
@@ -323,20 +323,16 @@ trait JoinSelectionHelper extends Logging {
if (hintOnly) {
hintToShuffleHashJoinLeft(join.hint)
} else {
- hintToPreferShuffleHashJoinLeft(join.hint) ||
- (!conf.preferSortMergeJoin && canBuildLocalHashMapBySize(join.left,
conf) &&
- muchSmaller(join.left, join.right, conf)) ||
- forceApplyShuffledHashJoin(conf)
+ (!conf.preferSortMergeJoin && canBuildLocalHashMapBySize(join.left,
conf) &&
+ muchSmaller(join.left, join.right, conf)) ||
forceApplyShuffledHashJoin(conf)
}
}
def shouldBuildRight(): Boolean = {
if (hintOnly) {
hintToShuffleHashJoinRight(join.hint)
} else {
- hintToPreferShuffleHashJoinRight(join.hint) ||
- (!conf.preferSortMergeJoin && canBuildLocalHashMapBySize(join.right,
conf) &&
- muchSmaller(join.right, join.left, conf)) ||
- forceApplyShuffledHashJoin(conf)
+ (!conf.preferSortMergeJoin && canBuildLocalHashMapBySize(join.right,
conf) &&
+ muchSmaller(join.right, join.left, conf)) ||
forceApplyShuffledHashJoin(conf)
}
}
getBuildSide(
@@ -473,18 +469,6 @@ trait JoinSelectionHelper extends Logging {
hint.rightHint.exists(_.strategy.contains(SHUFFLE_HASH))
}
- def hintToPreferShuffleHashJoinLeft(hint: JoinHint): Boolean = {
- hint.leftHint.exists(_.strategy.contains(PREFER_SHUFFLE_HASH))
- }
-
- def hintToPreferShuffleHashJoinRight(hint: JoinHint): Boolean = {
- hint.rightHint.exists(_.strategy.contains(PREFER_SHUFFLE_HASH))
- }
-
- def hintToPreferShuffleHashJoin(hint: JoinHint): Boolean = {
- hintToPreferShuffleHashJoinLeft(hint) ||
hintToPreferShuffleHashJoinRight(hint)
- }
-
def hintToShuffleHashJoin(hint: JoinHint): Boolean = {
hintToShuffleHashJoinLeft(hint) || hintToShuffleHashJoinRight(hint)
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/hints.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/hints.scala
index b7cd36f82db6..bb316f5683d8 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/hints.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/hints.scala
@@ -188,14 +188,6 @@ case object NO_BROADCAST_HASH extends JoinStrategyHint {
override def hintAliases: Set[String] = Set.empty
}
-/**
- * An internal hint to encourage shuffle hash join, used by adaptive query
execution.
- */
-case object PREFER_SHUFFLE_HASH extends JoinStrategyHint {
- override def displayName: String = "prefer_shuffle_hash"
- override def hintAliases: Set[String] = Set.empty
-}
-
/**
* An internal hint to prohibit broadcasting and replicating one side of a
join. This hint is used
* by some rules where broadcasting or replicating a particular side of the
join is not permitted,
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 aca325a43e85..132d38faac7c 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
@@ -1361,6 +1361,65 @@ 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).
This is the master " +
+ "switch for the conversion. By default it only looks through the
join's own required " +
+ "sort to reach a direct input shuffle; set " +
+
"spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.lookThroughOperators.enabled
" +
+ "to true to also look through non-shuffle operators (aggregate,
project, filter, " +
+ "window, etc.) sitting between the join and its input shuffle.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(true)
+
+ val
ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_LOOK_THROUGH_OPERATORS_ENABLED
=
+ buildConf(
+
"spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.lookThroughOperators.enabled")
+ .doc("When true, the sort merge join to shuffled hash join conversion
(see " +
+
s"${ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key})
additionally " +
+ "looks through non-shuffle operators (aggregate, project, filter,
window, etc.) sitting " +
+ "between the join and its input shuffle, instead of only the join's
own required sort. " +
+ s"Has no effect unless " +
+
s"${ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key} is
true.")
+ .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 when the
sort-merge-join to " +
+ "shuffled-hash-join conversion bounds 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 " +
+ "example a shuffled hash join that " +
+
s"${ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_ENABLED.key}
produced from a " +
+ "sort merge join when the conversion does not push extra sorts
elsewhere. Defaults to " +
+
"spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.lookThroughOperators.enabled
" +
+ "so that it is enabled together with the look-through conversion.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .fallbackConf(
+
ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_LOOK_THROUGH_OPERATORS_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 " +
@@ -8238,6 +8297,18 @@ 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 convertSortMergeJoinToShuffledHashJoinLookThroughOperatorsEnabled:
Boolean =
+
getConf(ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_LOOK_THROUGH_OPERATORS_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/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/LiteralExpressionSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/LiteralExpressionSuite.scala
index 58fff9bad849..85784579ad6d 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/LiteralExpressionSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/LiteralExpressionSuite.scala
@@ -38,7 +38,7 @@ import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types._
import org.apache.spark.sql.types.DayTimeIntervalType._
import org.apache.spark.sql.types.YearMonthIntervalType._
-import org.apache.spark.unsafe.types.{BinaryView, CalendarInterval,
TimestampNanosVal, UTF8String}
+import org.apache.spark.unsafe.types.{BinaryView, CalendarInterval,
TimestampNanosVal, UTF8String, VariantVal}
class LiteralExpressionSuite extends SparkFunSuite with ExpressionEvalHelper {
@@ -855,4 +855,52 @@ 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). Build the
+ // character with `Character.toChars` rather than a unicode escape:
scalariform decodes such an
+ // escape (even in a comment) before scalastyle's NonASCIICharacterChecker
sees it, so it would
+ // trip the nonascii lint despite the source being pure ASCII.
+ assert(Literal(UTF8String.fromString(new String(Character.toChars(0xE9))),
StringType)
+ .valueSizeInBytes === Some(2))
+ assert(Literal(Array[Byte](1, 2, 3, 4), BinaryType).valueSizeInBytes ===
Some(4))
+
+ // Array sums element sizes.
+ assert(Literal.create(Array("a", "bc"),
ArrayType(StringType)).valueSizeInBytes === Some(3))
+ assert(Literal.create(Array(1, 2, 3),
ArrayType(IntegerType)).valueSizeInBytes ===
+ Some(3 * IntegerType.defaultSize))
+
+ // Map sums key and value sizes.
+ assert(Literal.create(Map("a" -> "bc"), MapType(StringType,
StringType)).valueSizeInBytes ===
+ Some(3))
+
+ // Struct sums field sizes.
+ val structType = StructType(Seq(
+ StructField("s", StringType), StructField("i", IntegerType)))
+ assert(Literal.create(Row("abc", 1), structType).valueSizeInBytes ===
+ Some(3 + IntegerType.defaultSize))
+
+ // CalendarInterval reports its (fixed) default size.
+ assert(Literal(new CalendarInterval(1, 2, 3),
CalendarIntervalType).valueSizeInBytes ===
+ Some(CalendarIntervalType.defaultSize))
+
+ // An unmeasurable variable-length type (e.g. variant) returns None.
+ assert(Literal(new VariantVal(Array[Byte](1, 2), Array[Byte](3)),
VariantType).valueSizeInBytes
+ === None)
+ }
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
index 752ba33bdad2..f8f1e9eeeb35 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
@@ -206,8 +206,7 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
}
private def checkHintNonEquiJoin(hint: JoinHint): Unit = {
- if (hintToShuffleHashJoin(hint) || hintToPreferShuffleHashJoin(hint) ||
- hintToSortMergeJoin(hint)) {
+ if (hintToShuffleHashJoin(hint) || hintToSortMergeJoin(hint)) {
assert(hint.leftHint.orElse(hint.rightHint).isDefined)
hintErrorHandler.joinHintNotSupported(hint.leftHint.orElse(hint.rightHint).get,
"no equi-join keys")
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEOptimizer.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEOptimizer.scala
index 3c23930090ab..0a2c2090e7eb 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEOptimizer.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEOptimizer.scala
@@ -41,7 +41,7 @@ class AQEOptimizer(conf: SQLConf,
extendedRuntimeOptimizerRules: Seq[Rule[Logica
AQEPropagateEmptyRelation,
ConvertToLocalRelation,
UpdateAttributeNullability),
- Batch("Dynamic Join Selection", Once, DynamicJoinSelection),
+ Batch("Demote Broadcast Hash Join", Once, DemoteBroadcastHashJoin),
Batch("Eliminate Limits", fixedPoint, EliminateLimits),
Batch("Optimize One Row Plan", fixedPoint, OptimizeOneRowPlan)) :+
Batch("User Provided Runtime Optimizers", fixedPoint,
extendedRuntimeOptimizerRules: _*) :+
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..734404f210bb
--- /dev/null
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/ConvertSortMergeJoinToShuffledHashJoin.scala
@@ -0,0 +1,260 @@
+/*
+ * 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, 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 !hasSortMergeJoinHint(smj) && hashJoinSupported(leftKeys,
rightKeys) =>
+ val leftStage = findShuffleStage(left, lookThroughOperatorsEnabled)
+ val rightStage = findShuffleStage(right, lookThroughOperatorsEnabled)
+ // `wideningFactor` scales the input shuffle bytes by how much the
traversed operators
+ // widen each row, so its second argument must be the input shuffle
stage's output (not the
+ // join child's own output, which would always yield a ratio of 1.0).
Compute it only when
+ // the stage is known-defined.
+ val leftFactor = leftStage.map(s => wideningFactor(smj.left.output,
s.output))
+ val rightFactor = rightStage.map(s => wideningFactor(smj.right.output,
s.output))
+ val canBuildLeft = leftStage.isDefined &&
canBuildShuffledHashJoinLeft(smj.joinType) &&
+ preferShuffledHashJoin(leftStage.get.mapStats.get, leftFactor.get)
+ val canBuildRight = rightStage.isDefined &&
canBuildShuffledHashJoinRight(smj.joinType) &&
+ preferShuffledHashJoin(rightStage.get.mapStats.get, rightFactor.get)
+ val buildSide = if (canBuildLeft && canBuildRight) {
+ val leftSize = leftStage.get.mapStats.get.bytesByPartitionId.sum *
leftFactor.get
+ val rightSize = rightStage.get.mapStats.get.bytesByPartitionId.sum *
rightFactor.get
+ if (leftSize < rightSize) Some(BuildLeft) else Some(BuildRight)
+ } else if (canBuildLeft) {
+ Some(BuildLeft)
+ } else if (canBuildRight) {
+ Some(BuildRight)
+ } else {
+ None
+ }
+
+ buildSide 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. 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.
+ *
+ * The traversal 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.
+ *
+ * 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.
+ */
+ @tailrec
+ private def findShuffleStage(
+ plan: SparkPlan,
+ lookThroughOperatorsEnabled: Boolean): Option[ShuffleQueryStageExec] =
plan match {
+ case s: ShuffleQueryStageExec if s.isMaterialized && s.mapStats.isDefined
&&
+ s.shuffle.shuffleOrigin == ENSURE_REQUIREMENTS => Some(s)
+ // Always on: look through the join's own required sort to reach a
direct input shuffle.
+ case _: SortExec => findShuffleStage(plan.children.head,
lookThroughOperatorsEnabled)
+ // The look-through capability below is gated by its own config.
+ case _ if !lookThroughOperatorsEnabled => None
+ case _: FilterExec | _: WindowGroupLimitExec | _: CollectMetricsExec =>
+ findShuffleStage(plan.children.head, lookThroughOperatorsEnabled)
+ case p: ProjectExec if p.projectList.forall(isSizeBoundedExpr) =>
+ findShuffleStage(p.child, lookThroughOperatorsEnabled)
+ case a: BaseAggregateExec if a.resultExpressions.forall(isSizeBoundedExpr)
=>
+ findShuffleStage(a.child, lookThroughOperatorsEnabled)
+ case w: WindowExecBase if w.windowExpression.forall(isSizeBoundedExpr) =>
+ findShuffleStage(w.child, lookThroughOperatorsEnabled)
+ case join: BaseJoinExec =>
+ join.joinType match {
+ case LeftExistence(_) => findShuffleStage(join.left,
lookThroughOperatorsEnabled)
+ 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. A variable-width [[Literal]] is bounded only when
its actual byte size is
+ * no larger than the data type's `defaultSize` that [[wideningFactor]]
assumes; otherwise a large
+ * folded constant (e.g. `repeat('x', 100000)` constant-folded into a
`Literal`) would slip past
+ * the size estimate. Beyond that, only a whitelist of length-non-increasing
transforms over
+ * bounded children is accepted; anything else (e.g. `repeat`, `concat`,
`upper`/`lower` - Unicode
+ * case mapping can grow the UTF-8 byte length - 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 => true
+ // A variable-width literal is bounded only when its actual byte size is
known and no larger
+ // than the data type's `defaultSize` that `wideningFactor` assumes;
otherwise a large folded
+ // constant (e.g. `repeat('x', 100000)` constant-folded into a
`Literal`) would slip past the
+ // estimate. An unmeasurable literal (`valueSizeInBytes` is None) is
treated as widening.
+ case lit: Literal => lit.valueSizeInBytes.exists(_ <=
lit.dataType.defaultSize)
+ case e: Alias => isSizeBoundedExpr(e.child)
+ // 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: 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/DemoteBroadcastHashJoin.scala
similarity index 72%
rename from
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/DynamicJoinSelection.scala
rename to
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/DemoteBroadcastHashJoin.scala
index 217569ae645c..f86354247f56 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/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 {
private def hasManyEmptyPartitions(mapStats: MapOutputStatistics): Boolean =
{
val partitionCnt = mapStats.bytesByPartitionId.length
@@ -44,14 +38,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] = {
@@ -89,13 +75,8 @@ object DynamicJoinSelection extends Rule[LogicalPlan] with
JoinSelectionHelper {
false
}
- val preferShuffleHash = preferShuffledHashJoin(stage.mapStats.get)
- if (demoteBroadcastHash && preferShuffleHash) {
- Some(SHUFFLE_HASH)
- } else if (demoteBroadcastHash) {
+ if (demoteBroadcastHash) {
Some(NO_BROADCAST_HASH)
- } else if (preferShuffleHash) {
- Some(PREFER_SHUFFLE_HASH)
} else {
None
}
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/QueryPlanningTrackerEndToEndSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
index c23d4e354c8a..0b5307b467ff 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
@@ -87,13 +87,13 @@ class QueryPlanningTrackerEndToEndSuite extends StreamTest {
test("SPARK-57212: Track sub-query AQE rules") {
// The main query has no shuffle, so its AQE never re-optimizes and thus
never runs the AQE
// logical optimizer. The scalar sub-query does have a shuffle, so its
sub-AQE (planned on a
- // separate thread) re-optimizes and runs `DynamicJoinSelection`. That
rule is only visible in
- // the query tracker if every `AdaptiveSparkPlanExec` records into the
shared query tracker.
+ // separate thread) re-optimizes and runs `DemoteBroadcastHashJoin`. That
rule is only visible
+ // in the query tracker if every `AdaptiveSparkPlanExec` records into the
shared query tracker.
val df = spark.sql("SELECT id FROM range(10) WHERE id > (SELECT count(*)
FROM range(1000))")
df.collect()
val ruleNames = df.queryExecution.tracker.rules.keySet
assert(ruleNames.contains(
- "org.apache.spark.sql.execution.adaptive.DynamicJoinSelection"))
+ "org.apache.spark.sql.execution.adaptive.DemoteBroadcastHashJoin"))
}
test("The start times should be in order: parsing <= analysis <=
optimization <= planning") {
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 c3b8de4f37c1..4b57546475ac 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
@@ -50,6 +50,7 @@ import
org.apache.spark.sql.execution.ui.{SparkListenerSQLAdaptiveExecutionUpdat
import org.apache.spark.sql.execution.window.WindowExec
import org.apache.spark.sql.functions._
import org.apache.spark.sql.internal.SQLConf
+import
org.apache.spark.sql.internal.SQLConf.{ADAPTIVE_CONVERT_SORT_MERGE_JOIN_TO_SHUFFLED_HASH_JOIN_LOOK_THROUGH_OPERATORS_ENABLED
=> LOOK_THROUGH_OPERATORS}
import org.apache.spark.sql.internal.SQLConf.PartitionOverwriteMode
import org.apache.spark.sql.streaming.{OutputMode, StatefulProcessor,
TimeMode, TimerValues, TTLConfig, ValueState}
import org.apache.spark.sql.test.SharedSparkSession
@@ -70,6 +71,10 @@ class AdaptiveQueryExecSuite
setupTestData()
+ // Short alias for the long config key, to keep the SMJ-to-SHJ conversion
tests within the line
+ // length limit.
+ private val lookThroughOperatorsKey = LOOK_THROUGH_OPERATORS.key
+
override protected def sparkConf =
super.sparkConf.set(SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key,
"0")
@@ -2461,6 +2466,447 @@ 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: Widening factor uses the input shuffle's row width, not
the join child's") {
+ 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 widens each build row through a size-bounded
`cast(count(*) AS string)`:
+ // the input shuffle row is (c1: int, count: long) = 20 bytes/row, while
the build output is
+ // (c1: int, s: string) = 32 bytes/row, so the true widening factor is
32/20 = 1.6. The build
+ // side's largest shuffle partition is 372 bytes; scaled by 1.6 it is
~595, above the 500-byte
+ // local-map threshold, so the conversion must be rejected. If
`wideningFactor` were computed
+ // against the join child's own output (factor 1.0), the unscaled 372
would fit and the join
+ // would wrongly convert -- this test pins that the input shuffle's
width is used.
+ val query =
+ "SELECT t1.c1, x.s FROM t1 JOIN " +
+ "(SELECT c1, 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 ->
"500",
+ lookThroughOperatorsKey -> "true") {
+ val (_, adaptive) = runAdaptiveAndVerifyResult(query)
+ assert(findTopLevelShuffledHashJoin(adaptive).isEmpty,
+ "the widened build side exceeds the threshold, so the join must stay
a sort merge join")
+ assert(findTopLevelSortMergeJoin(adaptive).size === 1)
+ }
+ }
+ }
+
+ 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") {
+ // Both inputs have empty partitions. DemoteBroadcastHashJoin only
matches a direct
+ // LogicalQueryStage child, so it cannot demote the t1 side (behind the
aggregate) and
+ // instead tags the t2 side with a NO_BROADCAST_HASH hint. That hint
only forbids
+ // broadcasting and must not block converting the sort merge join to a
shuffled hash join.
+ // DemoteBroadcastHashJoin is left enabled (unlike the other conversion
tests) so the
+ // interaction is exercised.
+ spark.sparkContext.parallelize(
+ (1 to 2).map(i => TestData(i, i.toString)), 5)
+ .toDF("c1", "c2").createOrReplaceTempView("t1")
+ spark.sparkContext.parallelize(
+ (1 to 2).map(i => TestData(i, i.toString)), 10)
+ .toDF("c1", "c2").createOrReplaceTempView("t2")
+
+ // An aggregate sits between the join and t1's input shuffle, so the
logical
+ // DemoteBroadcastHashJoin rule cannot see a direct shuffle child and
only the physical rule
+ // can convert - which is exactly the look-through case this rule adds.
+ val query =
+ "SELECT x.c1, t2.c1 FROM " +
+ "(SELECT c1, count(*) AS cnt FROM t1 GROUP BY c1) x JOIN t2 ON x.c1
= t2.c1"
+
+ withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "6",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.NON_EMPTY_PARTITION_RATIO_FOR_BROADCAST_JOIN.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)
+ assert(findTopLevelShuffledHashJoin(adaptive).size === 1,
+ "a NO_BROADCAST_HASH hint must not block the conversion")
+ assert(findTopLevelSortMergeJoin(adaptive).isEmpty)
+ }
+ }
+ }
+
test("SPARK-35650: Coalesce number of partitions by AEQ") {
withSQLConf(SQLConf.COALESCE_PARTITIONS_MIN_PARTITION_NUM.key -> "1") {
Seq("REPARTITION", "REBALANCE(key)")
@@ -3735,6 +4181,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]].
*/
@@ -3749,7 +4205,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)
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]