dongjoon-hyun commented on code in PR #59703:
URL: https://github.com/apache/spark/pull/59703#discussion_r4349390218
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -1901,6 +1901,144 @@ class AdaptiveQueryExecSuite
}
}
+ test("SPARK-60458: skip only the skewed join whose split the plan cannot
take") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD.key -> "100",
+ SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100") {
+ withTempView("skewData1", "skewData2") {
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 3 as key1", "id as value1")
+ .createOrReplaceTempView("skewData1")
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 1 as key2", "id as value2")
+ .createOrReplaceTempView("skewData2")
+
+ val query =
+ """
+ |SELECT key1, count(*) FROM skewData1 JOIN skewData2 ON key1 =
key2 GROUP BY key1
+ |UNION ALL
+ |SELECT key1, 1 FROM skewData1 JOIN skewData2 ON key1 = key2
+ |""".stripMargin
+
+ // The aggregate above the first join needs its input on `key1`, which
the split breaks.
+ // The second join feeds the union directly, so its split breaks
nothing: it is kept.
+ val (_, adaptivePlan) = runAdaptiveAndVerifyResult(query)
+
+ // key2 is always 0, so only the 334 rows of key1 = 0 join, each
against all 1000 rows of
+ // the other side: the aggregated branch collapses them into one row,
the other keeps one
+ // row per joined row.
+ val rows = sql(query).collect()
+ assert(rows.length == 334001)
+ assert(rows.count(_ == Row(0L, 334000L)) == 1)
+ assert(rows.count(_ == Row(0L, 1L)) == 334000)
+
+ val aggregateJoins = collect(adaptivePlan) {
+ case agg: BaseAggregateExec =>
+ collect(agg) { case j: SortMergeJoinExec => j }
+ }.flatten
+ assert(aggregateJoins.size == 1)
Review Comment:
Minor: This holds only because `CombineAdjacentAggregation` merges the
partial and final aggregates over join1 into one `Complete` aggregate. Without
that merge, both aggregates sit above join1, and the nested `collect` at
L1939-1942 returns join1 once for each. So with the internal
`spark.sql.execution.combineAdjacentAggregation` off, or after any change that
stops that merge, this fails although the rule did the right thing. I checked
this by reading the code and did not run it. Could this take only the topmost
aggregate, e.g. `collectFirst(adaptivePlan) { case agg: BaseAggregateExec =>
findTopLevelSortMergeJoin(agg) }.getOrElse(Nil)`, or dedupe by identity?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/OptimizeSkewedJoin.scala:
##########
@@ -211,67 +211,138 @@ case class OptimizeSkewedJoin(ensureRequirements:
EnsureRequirements)
}
}
- def optimizeSkewJoin(plan: SparkPlan): SparkPlan = {
- val localShuffleReaderEnabled =
conf.getConf(SQLConf.LOCAL_SHUFFLE_READER_ENABLED)
- plan.transformUp {
+ /**
+ * The candidate `plan` stands for, when it is a sort-merge, shuffled-hash
or broadcast-hash join
+ * with a skewed shuffled side this rule can split.
+ */
+ private def splitSkewJoin(
+ plan: SparkPlan,
+ localShuffleReaderEnabled: Boolean): Option[SkewJoinCandidate] =
+ plan match {
case smj @ SortMergeJoinExec(_, _, joinType, _,
s1 @ SortExec(_, _, ShuffleStage(left: ShuffleQueryStageExec), _),
s2 @ SortExec(_, _, ShuffleStage(right: ShuffleQueryStageExec), _),
false) =>
tryOptimizeJoinChildren(left, right, joinType).map {
case (newLeft, newRight) =>
- smj.copy(
- left = s1.copy(child = newLeft), right = s2.copy(child =
newRight), isSkewJoin = true)
- }.getOrElse(smj)
+ SkewJoinCandidate(smj, joinType, smj.copy(
+ left = s1.copy(child = newLeft), right = s2.copy(child =
newRight),
+ isSkewJoin = true))
+ }
case shj @ ShuffledHashJoinExec(_, _, joinType, _, _,
ShuffleStage(left: ShuffleQueryStageExec),
ShuffleStage(right: ShuffleQueryStageExec), false) =>
tryOptimizeJoinChildren(left, right, joinType).map {
case (newLeft, newRight) =>
- shj.copy(left = newLeft, right = newRight, isSkewJoin = true)
- }.getOrElse(shj)
+ SkewJoinCandidate(
+ shj, joinType, shj.copy(left = newLeft, right = newRight,
isSkewJoin = true))
+ }
// When localShuffleReader is disabled, the streamedPlan of
BroadcastHashJoin may be skewed,
// so we try to optimize it.
- case bhj @ BroadcastHashJoinExec(_, _, _, BuildRight, _,
+ case bhj @ BroadcastHashJoinExec(_, _, joinType, BuildRight, _,
ShuffleStage(left: ShuffleQueryStageExec), _, _, false) if
!localShuffleReaderEnabled =>
tryOptimizeBroadcastHashJoinStreamedPlan(left).map {
- case newLeft => bhj.copy(left = newLeft, isSkewJoin = true)
- }.getOrElse(bhj)
- case bhj @ BroadcastHashJoinExec(_, _, _, BuildLeft, _, _,
+ case newLeft =>
+ SkewJoinCandidate(bhj, joinType, bhj.copy(left = newLeft,
isSkewJoin = true))
+ }
+ case bhj @ BroadcastHashJoinExec(_, _, joinType, BuildLeft, _, _,
ShuffleStage(right: ShuffleQueryStageExec), _, false) if
!localShuffleReaderEnabled =>
tryOptimizeBroadcastHashJoinStreamedPlan(right).map {
- case newRight => bhj.copy(right = newRight, isSkewJoin = true)
- }.getOrElse(bhj)
+ case newRight =>
+ SkewJoinCandidate(bhj, joinType, bhj.copy(right = newRight,
isSkewJoin = true))
+ }
+
+ case _ => None
}
+
+ /**
+ * Every join in `plan` this rule can split, in the bottom-up order a
`transformUp` visits them.
+ */
+ private def collectSplitSkewJoins(plan: SparkPlan): Seq[SkewJoinCandidate] =
{
+ val localShuffleReaderEnabled =
conf.getConf(SQLConf.LOCAL_SHUFFLE_READER_ENABLED)
+ val candidates = mutable.ArrayBuffer.empty[SkewJoinCandidate]
+ plan.foreachUp { operator =>
+ splitSkewJoin(operator, localShuffleReaderEnabled).foreach(candidates +=
_)
+ }
+ candidates.toSeq
}
- override def apply(plan: SparkPlan): SparkPlan = {
- if (!conf.getConf(SQLConf.SKEW_JOIN_ENABLED)) {
+ /**
+ * `plan` with `candidates` replaced by their splits. Candidates are matched
by node identity,
+ * so structurally equal joins in different places are distinct candidates.
A subset may be
+ * passed, keeping the others out of the result.
+ *
+ * The walk is top-down: a sort-merge or shuffled-hash candidate has shuffle
stages as children
+ * and contains nothing else, but a broadcast-hash join only constrains its
streamed side, so
+ * another candidate can sit in its build side. Replacing the outer join
first keeps that inner
+ * candidate an instance `plan` holds, and the walk then splits it inside
the replacement.
+ */
+ private[adaptive] def applySkewJoins(
+ plan: SparkPlan,
+ candidates: Seq[SkewJoinCandidate]): SparkPlan = {
+ if (candidates.isEmpty) {
return plan
}
+ plan.transformDown { operator =>
+ candidates.find(_.join eq
operator).map(_.replacement).getOrElse(operator)
+ }
+ }
- // We try to optimize every skewed sort-merge/shuffle-hash joins in the
query plan. If this
- // introduces extra shuffles, we give up the optimization and return the
original query plan, or
- // accept the extra shuffles if the force-apply config is true.
- // TODO: It's possible that only one skewed join in the query plan leads
to extra shuffles and
- // we only need to skip optimizing that join. We should make the
strategy smarter here.
- val optimized = optimizeSkewJoin(plan)
- val requirementSatisfied = if
(ensureRequirements.requiredDistribution.isDefined) {
- ValidateRequirements.validate(optimized,
ensureRequirements.requiredDistribution.get)
- } else {
- ValidateRequirements.validate(optimized)
+ private def requirementsSatisfied(plan: SparkPlan): Boolean = {
+ ensureRequirements.requiredDistribution match {
+ case Some(distribution) => ValidateRequirements.validate(plan,
distribution)
+ case None => ValidateRequirements.validate(plan)
}
- if (requirementSatisfied) {
- optimized.transform {
- case SkewJoinChildWrapper(child) => child
+ }
+
+ private def stripSkewJoinChildWrappers(plan: SparkPlan): SparkPlan = {
+ plan.transform { case SkewJoinChildWrapper(child) => child }
+ }
+
+ /**
+ * Applies the candidates one at a time, in the order a bottom-up pass
visits them, keeping a
+ * candidate when the plan holding the kept ones and this one still
satisfies every requirement.
+ * A split the plan cannot take therefore costs only its own join, where the
whole-plan fast path
+ * would have cost every join.
+ *
+ * Each trial is rebuilt from `plan` rather than from the previous trial, so
acceptance never
+ * depends on how a rebuild kept node instances; the handful of skewed joins
keeps that cheap.
+ */
+ private def applySkewJoinsSatisfyingRequirements(
+ plan: SparkPlan,
+ candidates: Seq[SkewJoinCandidate]): SparkPlan = {
+ var accepted = Seq.empty[SkewJoinCandidate]
+ candidates.foreach { candidate =>
+ val trial = applySkewJoins(plan, accepted :+ candidate)
+ if (requirementsSatisfied(trial)) {
+ accepted = accepted :+ candidate
+ } else {
+ logDebug(s"Skip splitting the skewed ${candidate.joinType} join " +
+ s"${candidate.join.nodeName}: with it the plan fails
ValidateRequirements")
}
+ }
+ logDebug(s"Optimized ${accepted.length} of ${candidates.length} skewed
joins")
+ stripSkewJoinChildWrappers(applySkewJoins(plan, accepted))
+ }
+
+ override def apply(plan: SparkPlan): SparkPlan = {
+ if (!conf.getConf(SQLConf.SKEW_JOIN_ENABLED)) {
+ return plan
+ }
+
+ // Try every split at once first: one pass to find the candidates and one
validation cover the
+ // common case. When that breaks a requirement of the plan, either accept
the extra shuffles if
+ // the force-apply config is true, or keep only the joins whose splits the
plan can take. The
+ // candidates are collected once, so the fallback reuses the splits the
fast path built.
+ val candidates = collectSplitSkewJoins(plan)
+ val optimized = applySkewJoins(plan, candidates)
+ if (requirementsSatisfied(optimized)) {
+ stripSkewJoinChildWrappers(optimized)
} else if (conf.getConf(SQLConf.ADAPTIVE_FORCE_OPTIMIZE_SKEWED_JOIN)) {
- ensureRequirements.apply(optimized).transform {
- case SkewJoinChildWrapper(child) => child
- }
+ stripSkewJoinChildWrappers(ensureRequirements.apply(optimized))
} else {
Review Comment:
Minor: This branch used to return `plan`, and nothing turns the new fallback
off. Every AQE query whose all-at-once split fails validation with
`spark.sql.adaptive.forceOptimizeSkewedJoin` off now gets a different plan at
runtime, with trade-offs such as the coalescing one at L345. If the fallback
misbehaves, the only way back is `spark.sql.adaptive.skewJoin.enabled=false`,
which also drops the splits the old rule made, and
`spark.sql.adaptive.optimizer.excludedRules` cannot exclude this rule because
it filters only `AQEOptimizer`'s logical rules (AQEOptimizer.scala:50-61).
Neighboring rules have such a switch, e.g.
`spark.sql.execution.combineAdjacentAggregation`, and SPARK-52921 added
`spark.sql.unionOutputPartitioning` for a plan-shape change like this one.
Could an internal conf, e.g.
`spark.sql.adaptive.skewJoin.perJoinFallback.enabled` with default true and
version 4.4.0, guard this branch and return `plan` when it is off?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/OptimizeSkewedJoin.scala:
##########
@@ -211,67 +211,138 @@ case class OptimizeSkewedJoin(ensureRequirements:
EnsureRequirements)
}
}
- def optimizeSkewJoin(plan: SparkPlan): SparkPlan = {
- val localShuffleReaderEnabled =
conf.getConf(SQLConf.LOCAL_SHUFFLE_READER_ENABLED)
- plan.transformUp {
+ /**
+ * The candidate `plan` stands for, when it is a sort-merge, shuffled-hash
or broadcast-hash join
+ * with a skewed shuffled side this rule can split.
+ */
+ private def splitSkewJoin(
+ plan: SparkPlan,
+ localShuffleReaderEnabled: Boolean): Option[SkewJoinCandidate] =
+ plan match {
case smj @ SortMergeJoinExec(_, _, joinType, _,
s1 @ SortExec(_, _, ShuffleStage(left: ShuffleQueryStageExec), _),
s2 @ SortExec(_, _, ShuffleStage(right: ShuffleQueryStageExec), _),
false) =>
tryOptimizeJoinChildren(left, right, joinType).map {
case (newLeft, newRight) =>
- smj.copy(
- left = s1.copy(child = newLeft), right = s2.copy(child =
newRight), isSkewJoin = true)
- }.getOrElse(smj)
+ SkewJoinCandidate(smj, joinType, smj.copy(
+ left = s1.copy(child = newLeft), right = s2.copy(child =
newRight),
+ isSkewJoin = true))
+ }
case shj @ ShuffledHashJoinExec(_, _, joinType, _, _,
ShuffleStage(left: ShuffleQueryStageExec),
ShuffleStage(right: ShuffleQueryStageExec), false) =>
tryOptimizeJoinChildren(left, right, joinType).map {
case (newLeft, newRight) =>
- shj.copy(left = newLeft, right = newRight, isSkewJoin = true)
- }.getOrElse(shj)
+ SkewJoinCandidate(
+ shj, joinType, shj.copy(left = newLeft, right = newRight,
isSkewJoin = true))
+ }
// When localShuffleReader is disabled, the streamedPlan of
BroadcastHashJoin may be skewed,
// so we try to optimize it.
- case bhj @ BroadcastHashJoinExec(_, _, _, BuildRight, _,
+ case bhj @ BroadcastHashJoinExec(_, _, joinType, BuildRight, _,
ShuffleStage(left: ShuffleQueryStageExec), _, _, false) if
!localShuffleReaderEnabled =>
tryOptimizeBroadcastHashJoinStreamedPlan(left).map {
- case newLeft => bhj.copy(left = newLeft, isSkewJoin = true)
- }.getOrElse(bhj)
- case bhj @ BroadcastHashJoinExec(_, _, _, BuildLeft, _, _,
+ case newLeft =>
+ SkewJoinCandidate(bhj, joinType, bhj.copy(left = newLeft,
isSkewJoin = true))
+ }
+ case bhj @ BroadcastHashJoinExec(_, _, joinType, BuildLeft, _, _,
ShuffleStage(right: ShuffleQueryStageExec), _, false) if
!localShuffleReaderEnabled =>
tryOptimizeBroadcastHashJoinStreamedPlan(right).map {
- case newRight => bhj.copy(right = newRight, isSkewJoin = true)
- }.getOrElse(bhj)
+ case newRight =>
+ SkewJoinCandidate(bhj, joinType, bhj.copy(right = newRight,
isSkewJoin = true))
+ }
+
+ case _ => None
}
+
+ /**
+ * Every join in `plan` this rule can split, in the bottom-up order a
`transformUp` visits them.
+ */
+ private def collectSplitSkewJoins(plan: SparkPlan): Seq[SkewJoinCandidate] =
{
+ val localShuffleReaderEnabled =
conf.getConf(SQLConf.LOCAL_SHUFFLE_READER_ENABLED)
+ val candidates = mutable.ArrayBuffer.empty[SkewJoinCandidate]
+ plan.foreachUp { operator =>
+ splitSkewJoin(operator, localShuffleReaderEnabled).foreach(candidates +=
_)
+ }
+ candidates.toSeq
}
- override def apply(plan: SparkPlan): SparkPlan = {
- if (!conf.getConf(SQLConf.SKEW_JOIN_ENABLED)) {
+ /**
+ * `plan` with `candidates` replaced by their splits. Candidates are matched
by node identity,
+ * so structurally equal joins in different places are distinct candidates.
A subset may be
+ * passed, keeping the others out of the result.
+ *
+ * The walk is top-down: a sort-merge or shuffled-hash candidate has shuffle
stages as children
+ * and contains nothing else, but a broadcast-hash join only constrains its
streamed side, so
+ * another candidate can sit in its build side. Replacing the outer join
first keeps that inner
+ * candidate an instance `plan` holds, and the walk then splits it inside
the replacement.
+ */
+ private[adaptive] def applySkewJoins(
+ plan: SparkPlan,
+ candidates: Seq[SkewJoinCandidate]): SparkPlan = {
+ if (candidates.isEmpty) {
return plan
}
+ plan.transformDown { operator =>
+ candidates.find(_.join eq
operator).map(_.replacement).getOrElse(operator)
+ }
+ }
- // We try to optimize every skewed sort-merge/shuffle-hash joins in the
query plan. If this
- // introduces extra shuffles, we give up the optimization and return the
original query plan, or
- // accept the extra shuffles if the force-apply config is true.
- // TODO: It's possible that only one skewed join in the query plan leads
to extra shuffles and
- // we only need to skip optimizing that join. We should make the
strategy smarter here.
- val optimized = optimizeSkewJoin(plan)
- val requirementSatisfied = if
(ensureRequirements.requiredDistribution.isDefined) {
- ValidateRequirements.validate(optimized,
ensureRequirements.requiredDistribution.get)
- } else {
- ValidateRequirements.validate(optimized)
+ private def requirementsSatisfied(plan: SparkPlan): Boolean = {
+ ensureRequirements.requiredDistribution match {
+ case Some(distribution) => ValidateRequirements.validate(plan,
distribution)
+ case None => ValidateRequirements.validate(plan)
}
- if (requirementSatisfied) {
- optimized.transform {
- case SkewJoinChildWrapper(child) => child
+ }
+
+ private def stripSkewJoinChildWrappers(plan: SparkPlan): SparkPlan = {
+ plan.transform { case SkewJoinChildWrapper(child) => child }
+ }
+
+ /**
+ * Applies the candidates one at a time, in the order a bottom-up pass
visits them, keeping a
+ * candidate when the plan holding the kept ones and this one still
satisfies every requirement.
+ * A split the plan cannot take therefore costs only its own join, where the
whole-plan fast path
+ * would have cost every join.
+ *
+ * Each trial is rebuilt from `plan` rather than from the previous trial, so
acceptance never
+ * depends on how a rebuild kept node instances; the handful of skewed joins
keeps that cheap.
+ */
+ private def applySkewJoinsSatisfyingRequirements(
+ plan: SparkPlan,
+ candidates: Seq[SkewJoinCandidate]): SparkPlan = {
+ var accepted = Seq.empty[SkewJoinCandidate]
+ candidates.foreach { candidate =>
+ val trial = applySkewJoins(plan, accepted :+ candidate)
+ if (requirementsSatisfied(trial)) {
+ accepted = accepted :+ candidate
+ } else {
+ logDebug(s"Skip splitting the skewed ${candidate.joinType} join " +
+ s"${candidate.join.nodeName}: with it the plan fails
ValidateRequirements")
}
+ }
+ logDebug(s"Optimized ${accepted.length} of ${candidates.length} skewed
joins")
+ stripSkewJoinChildWrappers(applySkewJoins(plan, accepted))
+ }
+
+ override def apply(plan: SparkPlan): SparkPlan = {
+ if (!conf.getConf(SQLConf.SKEW_JOIN_ENABLED)) {
+ return plan
+ }
+
+ // Try every split at once first: one pass to find the candidates and one
validation cover the
+ // common case. When that breaks a requirement of the plan, either accept
the extra shuffles if
+ // the force-apply config is true, or keep only the joins whose splits the
plan can take. The
+ // candidates are collected once, so the fallback reuses the splits the
fast path built.
+ val candidates = collectSplitSkewJoins(plan)
+ val optimized = applySkewJoins(plan, candidates)
+ if (requirementsSatisfied(optimized)) {
+ stripSkewJoinChildWrappers(optimized)
} else if (conf.getConf(SQLConf.ADAPTIVE_FORCE_OPTIMIZE_SKEWED_JOIN)) {
- ensureRequirements.apply(optimized).transform {
- case SkewJoinChildWrapper(child) => child
- }
+ stripSkewJoinChildWrappers(ensureRequirements.apply(optimized))
} else {
- plan
+ applySkewJoinsSatisfyingRequirements(plan, candidates)
Review Comment:
**When this fallback keeps one union branch's split and rejects another,
`CoalesceShufflePartitions` skips coalescing for every stage under that union,
while before this PR nothing there was split and all of them were coalesced.**
Take the first new test's query. `StampUnionDecisions` runs before this rule
(AdaptiveSparkPlanExec.scala:155 and 175), when both branches still report
`hashpartitioning(key1)`, so the union is stamped non-plain. Here join2's split
is kept and join1's is rejected, so the union then reports
`UnknownPartitioning` and `unionRDDs` concatenates. But
`childrenNeedCompatiblePartitioning` reads the stamp (`case u: UnionExec =>
!u.isPlainUnion` at CoalesceShufflePartitions.scala:189, which reads it since
SPARK-59122). So join1's two stages, which carry no partition specs, and
join2's two skew reads form one coalesce group, and
`coalescePartitionsWithSkew` logs "Could not apply partition coalescing because
of missing MapOutputStatistics or shuffle partition specs.
" and gives up (ShufflePartitionsUtil.scala:135-140). Results are unchanged,
but join1's branch and join2's non-skewed partitions now run at the full
shuffle partition count, while join1, still unsplit, keeps its straggler. The
third case of SPARK-37652 takes the same path. Before this PR, a union could
already reach this with a split join next to a branch that was never skewed,
but now the shape this PR targets reaches it too. Only master and branch-4.x
are affected, since branch-4.2 and earlier have `case _: UnionExec => false`
there. I traced this by reading the code and did not run it. Could
`CoalesceShufflePartitions` group a union's children only while the union still
reports a real partitioning, e.g. `case u: UnionExec => !u.isPlainUnion &&
!u.outputPartitioning.isInstanceOf[UnknownPartitioning]` as its comment at
CoalesceShufflePartitions.scala:185-188 describes, here or in a separate JIRA,
with the first test asserting that join1's children get an `AQEShuffleReadExec`?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -1901,6 +1901,144 @@ class AdaptiveQueryExecSuite
}
}
+ test("SPARK-60458: skip only the skewed join whose split the plan cannot
take") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD.key -> "100",
+ SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100") {
+ withTempView("skewData1", "skewData2") {
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 3 as key1", "id as value1")
+ .createOrReplaceTempView("skewData1")
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 1 as key2", "id as value2")
+ .createOrReplaceTempView("skewData2")
+
+ val query =
+ """
+ |SELECT key1, count(*) FROM skewData1 JOIN skewData2 ON key1 =
key2 GROUP BY key1
+ |UNION ALL
+ |SELECT key1, 1 FROM skewData1 JOIN skewData2 ON key1 = key2
+ |""".stripMargin
+
+ // The aggregate above the first join needs its input on `key1`, which
the split breaks.
+ // The second join feeds the union directly, so its split breaks
nothing: it is kept.
+ val (_, adaptivePlan) = runAdaptiveAndVerifyResult(query)
+
+ // key2 is always 0, so only the 334 rows of key1 = 0 join, each
against all 1000 rows of
+ // the other side: the aggregated branch collapses them into one row,
the other keeps one
+ // row per joined row.
+ val rows = sql(query).collect()
+ assert(rows.length == 334001)
+ assert(rows.count(_ == Row(0L, 334000L)) == 1)
+ assert(rows.count(_ == Row(0L, 1L)) == 334000)
+
+ val aggregateJoins = collect(adaptivePlan) {
Review Comment:
Nit: These lines are
`findTopLevelAggregate(adaptivePlan).flatMap(findTopLevelSortMergeJoin)`
(L181-185 and L157-161), and `collect(result) { case j: SortMergeJoinExec => j
}` at L2038 is `findTopLevelSortMergeJoin(result)`. L1945 and L1976 already use
the helper. Could both use the helpers, here in the form my item 3 suggests?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -1901,6 +1901,144 @@ class AdaptiveQueryExecSuite
}
}
+ test("SPARK-60458: skip only the skewed join whose split the plan cannot
take") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD.key -> "100",
+ SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100") {
+ withTempView("skewData1", "skewData2") {
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 3 as key1", "id as value1")
+ .createOrReplaceTempView("skewData1")
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 1 as key2", "id as value2")
+ .createOrReplaceTempView("skewData2")
+
+ val query =
+ """
+ |SELECT key1, count(*) FROM skewData1 JOIN skewData2 ON key1 =
key2 GROUP BY key1
+ |UNION ALL
+ |SELECT key1, 1 FROM skewData1 JOIN skewData2 ON key1 = key2
+ |""".stripMargin
+
+ // The aggregate above the first join needs its input on `key1`, which
the split breaks.
+ // The second join feeds the union directly, so its split breaks
nothing: it is kept.
+ val (_, adaptivePlan) = runAdaptiveAndVerifyResult(query)
+
+ // key2 is always 0, so only the 334 rows of key1 = 0 join, each
against all 1000 rows of
+ // the other side: the aggregated branch collapses them into one row,
the other keeps one
+ // row per joined row.
+ val rows = sql(query).collect()
+ assert(rows.length == 334001)
+ assert(rows.count(_ == Row(0L, 334000L)) == 1)
+ assert(rows.count(_ == Row(0L, 1L)) == 334000)
+
+ val aggregateJoins = collect(adaptivePlan) {
+ case agg: BaseAggregateExec =>
+ collect(agg) { case j: SortMergeJoinExec => j }
+ }.flatten
+ assert(aggregateJoins.size == 1)
+ assert(!aggregateJoins.head.isSkewJoin)
+ val unionJoins = findTopLevelSortMergeJoin(adaptivePlan)
+ .filterNot(j => aggregateJoins.exists(_ eq j))
+ assert(unionJoins.size == 1)
+ assert(unionJoins.head.isSkewJoin)
+ assert(collect(unionJoins.head) {
+ case r: AQEShuffleReadExec => r
+
}.exists(_.partitionSpecs.exists(_.isInstanceOf[PartialReducerPartitionSpec])))
Review Comment:
Nit: This is `AQEShuffleReadExec.hasSkewedPartition`
(AQEShuffleReadExec.scala:144-145), which this suite already uses, e.g. at
L2605 and L2660. Could this be `}.exists(_.hasSkewedPartition))`?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -1901,6 +1901,144 @@ class AdaptiveQueryExecSuite
}
}
+ test("SPARK-60458: skip only the skewed join whose split the plan cannot
take") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD.key -> "100",
+ SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100") {
+ withTempView("skewData1", "skewData2") {
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 3 as key1", "id as value1")
+ .createOrReplaceTempView("skewData1")
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 1 as key2", "id as value2")
+ .createOrReplaceTempView("skewData2")
+
+ val query =
+ """
+ |SELECT key1, count(*) FROM skewData1 JOIN skewData2 ON key1 =
key2 GROUP BY key1
+ |UNION ALL
+ |SELECT key1, 1 FROM skewData1 JOIN skewData2 ON key1 = key2
+ |""".stripMargin
+
+ // The aggregate above the first join needs its input on `key1`, which
the split breaks.
+ // The second join feeds the union directly, so its split breaks
nothing: it is kept.
+ val (_, adaptivePlan) = runAdaptiveAndVerifyResult(query)
+
+ // key2 is always 0, so only the 334 rows of key1 = 0 join, each
against all 1000 rows of
+ // the other side: the aggregated branch collapses them into one row,
the other keeps one
+ // row per joined row.
+ val rows = sql(query).collect()
+ assert(rows.length == 334001)
+ assert(rows.count(_ == Row(0L, 334000L)) == 1)
+ assert(rows.count(_ == Row(0L, 1L)) == 334000)
+
+ val aggregateJoins = collect(adaptivePlan) {
+ case agg: BaseAggregateExec =>
+ collect(agg) { case j: SortMergeJoinExec => j }
+ }.flatten
+ assert(aggregateJoins.size == 1)
+ assert(!aggregateJoins.head.isSkewJoin)
+ val unionJoins = findTopLevelSortMergeJoin(adaptivePlan)
+ .filterNot(j => aggregateJoins.exists(_ eq j))
+ assert(unionJoins.size == 1)
+ assert(unionJoins.head.isSkewJoin)
+ assert(collect(unionJoins.head) {
+ case r: AQEShuffleReadExec => r
+
}.exists(_.partitionSpecs.exists(_.isInstanceOf[PartialReducerPartitionSpec])))
+ assert(ValidateRequirements.validate(adaptivePlan))
+ }
+ }
+ }
+
+ test("SPARK-60458: keep the layout a union passes through") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD.key -> "100",
+ SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100") {
+ withTempView("skewData1", "skewData2") {
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 3 as key1", "id as value1")
+ .createOrReplaceTempView("skewData1")
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 1 as key2", "id as value2")
+ .createOrReplaceTempView("skewData2")
+
+ def splitJoins(query: String): Seq[SortMergeJoinExec] = {
+ val (_, adaptivePlan) = runAdaptiveAndVerifyResult(query)
+ assert(ValidateRequirements.validate(adaptivePlan))
+ val joins = findTopLevelSortMergeJoin(adaptivePlan)
+ assert(joins.size == 2, adaptivePlan.toString)
+ joins
+ }
+
+ val union =
+ """
+ |SELECT key1 AS k, value1 AS v FROM skewData1 JOIN skewData2 ON
key1 = key2
+ |UNION ALL
+ |SELECT key1 AS k, value1 AS v FROM skewData1 JOIN skewData2 ON
key1 = key2
+ |""".stripMargin
+ val windowedQuery =
+ s"SELECT k, rank() OVER (PARTITION BY k ORDER BY v) FROM ($union) u"
+
+ // Both branches report the join's hash layout, so the union claims it
and the window above
+ // depends on it: a split takes the claim away from both branches. The
all-at-once attempt
+ // rejects this plan too; this arm pins that the per-join fallback
keeps no split the claim
+ // cannot take, and the arm below that the same splits are kept when
nothing reads the
+ // claim.
+ val windowed = splitJoins(windowedQuery)
+ assert(windowed.forall(j => !j.isSkewJoin))
+ // Each branch holds the 334000 joined rows of key1 = 0, so across the
union every v of
+ // 0, 3, ..., 999 appears 2000 times and shares its rank with its
duplicates.
+ val windowedRows = sql(windowedQuery).collect()
+ assert(windowedRows.length == 668000)
+ assert(windowedRows.forall(_.getLong(0) == 0L))
+
assert(windowedRows.groupBy(_.getInt(1)).view.mapValues(_.length).toMap ==
+ (1 to 666001 by 2000).map(_ -> 2000).toMap)
+
+ // Nothing above reads the union's layout, so the same splits break
nothing and are kept.
+ val plain = splitJoins(union)
+ assert(plain.forall(_.isSkewJoin))
+ val plainRows = sql(union).collect()
+ assert(plainRows.length == 668000)
+ assert(plainRows.groupBy(_.getLong(1)).view.mapValues(_.length).toMap
==
+ (0L until 1000L by 3).map(_ -> 2000).toMap)
+ }
+ }
+ }
+
+ test("SPARK-60458: split a join nested in a broadcast join's build side") {
Review Comment:
Minor: The PR description predates this test and 351074c. It still says "Two
new tests in `AdaptiveQueryExecSuite`" and "Full suite 148/148 on the rebased
head", while this commit adds this third test and the suite now has 149 tests,
and its implementation notes do not say that `applySkewJoins` now walks
top-down so that a join nested in a broadcast-hash join's build side keeps its
split. It also says only the first arm of the union test passes on the base
code, but the plain arm passes there too, as the control. Could the description
be updated, since it becomes the commit message?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/OptimizeSkewedJoin.scala:
##########
@@ -285,6 +356,10 @@ case class OptimizeSkewedJoin(ensureRequirements:
EnsureRequirements)
}
}
+// A skewed join `OptimizeSkewedJoin` can split: the join node in the plan,
its join type, and the
+// join it is replaced by, with the skewed side split.
+private case class SkewJoinCandidate(join: SparkPlan, joinType: JoinType,
replacement: SparkPlan)
Review Comment:
Nit: `joinType` repeats `join.joinType`, since all three joins extend
`BaseJoinExec` (BaseJoinExec.scala:27-28). Its only reader is the debug log at
L321-322, and the broadcast arms changed `_` to a `joinType` binder (L243 and
L249) only to fill it. That log also cannot tell joins apart: in the union
test's windowed arm it prints "Skip splitting the skewed Inner join
SortMergeJoin: ..." twice. Could this be `SkewJoinCandidate(join: BaseJoinExec,
replacement: SparkPlan)`, with the log printing
`candidate.join.simpleString(conf.maxToStringFields)`, which shows the keys and
the join type?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/OptimizeSkewedJoin.scala:
##########
@@ -211,67 +211,138 @@ case class OptimizeSkewedJoin(ensureRequirements:
EnsureRequirements)
}
}
- def optimizeSkewJoin(plan: SparkPlan): SparkPlan = {
- val localShuffleReaderEnabled =
conf.getConf(SQLConf.LOCAL_SHUFFLE_READER_ENABLED)
- plan.transformUp {
+ /**
+ * The candidate `plan` stands for, when it is a sort-merge, shuffled-hash
or broadcast-hash join
+ * with a skewed shuffled side this rule can split.
+ */
+ private def splitSkewJoin(
+ plan: SparkPlan,
+ localShuffleReaderEnabled: Boolean): Option[SkewJoinCandidate] =
+ plan match {
case smj @ SortMergeJoinExec(_, _, joinType, _,
s1 @ SortExec(_, _, ShuffleStage(left: ShuffleQueryStageExec), _),
s2 @ SortExec(_, _, ShuffleStage(right: ShuffleQueryStageExec), _),
false) =>
tryOptimizeJoinChildren(left, right, joinType).map {
case (newLeft, newRight) =>
- smj.copy(
- left = s1.copy(child = newLeft), right = s2.copy(child =
newRight), isSkewJoin = true)
- }.getOrElse(smj)
+ SkewJoinCandidate(smj, joinType, smj.copy(
+ left = s1.copy(child = newLeft), right = s2.copy(child =
newRight),
+ isSkewJoin = true))
+ }
case shj @ ShuffledHashJoinExec(_, _, joinType, _, _,
ShuffleStage(left: ShuffleQueryStageExec),
ShuffleStage(right: ShuffleQueryStageExec), false) =>
tryOptimizeJoinChildren(left, right, joinType).map {
case (newLeft, newRight) =>
- shj.copy(left = newLeft, right = newRight, isSkewJoin = true)
- }.getOrElse(shj)
+ SkewJoinCandidate(
+ shj, joinType, shj.copy(left = newLeft, right = newRight,
isSkewJoin = true))
+ }
// When localShuffleReader is disabled, the streamedPlan of
BroadcastHashJoin may be skewed,
// so we try to optimize it.
- case bhj @ BroadcastHashJoinExec(_, _, _, BuildRight, _,
+ case bhj @ BroadcastHashJoinExec(_, _, joinType, BuildRight, _,
ShuffleStage(left: ShuffleQueryStageExec), _, _, false) if
!localShuffleReaderEnabled =>
tryOptimizeBroadcastHashJoinStreamedPlan(left).map {
- case newLeft => bhj.copy(left = newLeft, isSkewJoin = true)
- }.getOrElse(bhj)
- case bhj @ BroadcastHashJoinExec(_, _, _, BuildLeft, _, _,
+ case newLeft =>
+ SkewJoinCandidate(bhj, joinType, bhj.copy(left = newLeft,
isSkewJoin = true))
+ }
+ case bhj @ BroadcastHashJoinExec(_, _, joinType, BuildLeft, _, _,
ShuffleStage(right: ShuffleQueryStageExec), _, false) if
!localShuffleReaderEnabled =>
tryOptimizeBroadcastHashJoinStreamedPlan(right).map {
- case newRight => bhj.copy(right = newRight, isSkewJoin = true)
- }.getOrElse(bhj)
+ case newRight =>
+ SkewJoinCandidate(bhj, joinType, bhj.copy(right = newRight,
isSkewJoin = true))
+ }
+
+ case _ => None
}
+
+ /**
+ * Every join in `plan` this rule can split, in the bottom-up order a
`transformUp` visits them.
+ */
+ private def collectSplitSkewJoins(plan: SparkPlan): Seq[SkewJoinCandidate] =
{
+ val localShuffleReaderEnabled =
conf.getConf(SQLConf.LOCAL_SHUFFLE_READER_ENABLED)
+ val candidates = mutable.ArrayBuffer.empty[SkewJoinCandidate]
+ plan.foreachUp { operator =>
+ splitSkewJoin(operator, localShuffleReaderEnabled).foreach(candidates +=
_)
+ }
+ candidates.toSeq
}
- override def apply(plan: SparkPlan): SparkPlan = {
- if (!conf.getConf(SQLConf.SKEW_JOIN_ENABLED)) {
+ /**
+ * `plan` with `candidates` replaced by their splits. Candidates are matched
by node identity,
+ * so structurally equal joins in different places are distinct candidates.
A subset may be
+ * passed, keeping the others out of the result.
+ *
+ * The walk is top-down: a sort-merge or shuffled-hash candidate has shuffle
stages as children
Review Comment:
Nit: A sort-merge candidate's children are the `SortExec`s at L223-224, with
the stages under them; only a shuffled-hash candidate has stages as children.
Two more new doc comments are off: L260 still says "the bottom-up order a
`transformUp` visits them", though this file no longer uses `transformUp` (the
order is `foreachUp`'s), and L215, "The candidate `plan` stands for, when it is
...", does not say that it returns `None` otherwise. Could these read "a
shuffled-hash candidate has shuffle stages as children and a sort-merge one has
sorts over them, so neither contains another candidate", "in the bottom-up
order `foreachUp` visits them", and "Returns the candidate for `plan` when it
is ..., and `None` otherwise"?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -1901,6 +1901,144 @@ class AdaptiveQueryExecSuite
}
}
+ test("SPARK-60458: skip only the skewed join whose split the plan cannot
take") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SKEW_JOIN_SKEWED_PARTITION_THRESHOLD.key -> "100",
+ SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES.key -> "100") {
+ withTempView("skewData1", "skewData2") {
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 3 as key1", "id as value1")
+ .createOrReplaceTempView("skewData1")
+ spark
+ .range(0, 1000, 1, 10)
+ .selectExpr("id % 1 as key2", "id as value2")
+ .createOrReplaceTempView("skewData2")
+
+ val query =
+ """
+ |SELECT key1, count(*) FROM skewData1 JOIN skewData2 ON key1 =
key2 GROUP BY key1
+ |UNION ALL
+ |SELECT key1, 1 FROM skewData1 JOIN skewData2 ON key1 = key2
+ |""".stripMargin
+
+ // The aggregate above the first join needs its input on `key1`, which
the split breaks.
+ // The second join feeds the union directly, so its split breaks
nothing: it is kept.
+ val (_, adaptivePlan) = runAdaptiveAndVerifyResult(query)
+
+ // key2 is always 0, so only the 334 rows of key1 = 0 join, each
against all 1000 rows of
+ // the other side: the aggregated branch collapses them into one row,
the other keeps one
+ // row per joined row.
+ val rows = sql(query).collect()
Review Comment:
Nit: `runAdaptiveAndVerifyResult` already collects the AQE answer and
compares it with the non-AQE one (L119-123), and this rule changes only the AQE
plan, so a wrong split already fails there. This line, L1999 and L2008 run each
query once more and collect 334,001 or 668,000 rows to the driver in a
`@SlowSQLTest` suite, against constants derived by hand. Could these three
blocks go, keeping the plan assertions?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]