comphead commented on code in PR #6785:
URL: https://github.com/apache/datafusion-comet/pull/6785#discussion_r4231743130
##########
spark/src/test/scala/org/apache/comet/exec/CometJoinSuite.scala:
##########
@@ -1219,6 +1251,237 @@ class CometJoinSuite extends CometTestBase {
}
}
+ // Spark removes a sort above a sort-merge join whose output ordering
satisfies it, so the
+ // forced hash join must be chosen before that happens or the sort is lost
(#6770).
+ private def withSortLossConf(adaptive: Boolean)(f: => Unit): Unit =
withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> adaptive.toString,
+ SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2",
+ CometConf.COMET_FORCE_SHJ.key -> "true") {
+ withParquetTable((0 until 10000).map(i => (i % 100, i)), "big") {
+ withParquetTable((0 until 10).map(i => (i * 10, i)), "small") {
Review Comment:
This repeats setup the suite already has. `big` and `small` are the same
data that `withChainedJoinTables` builds, and the five configs here are the
ones `withKeptSemiJoinConf` and the window test already set (they also set
`maxBuildSize=-1`). Could this helper call `withChainedJoinTables(midRows =
3000, midKeys = 100)` for the tables, and could the config block become one
class-level helper that takes `adaptive` and serves the semi join, window and
sort tests? I have not run it. The unused `mid` table and `maxBuildSize=-1`
should not change these plans, since the build side is always `small`.
##########
spark/src/main/scala/org/apache/comet/rules/CometShuffledHashJoinStrategy.scala:
##########
@@ -0,0 +1,194 @@
+/*
+ * 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.comet.rules
+
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.expressions.RowOrdering
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight,
BuildSide}
+import org.apache.spark.sql.catalyst.planning.ExtractEquiJoinKeys
+import org.apache.spark.sql.catalyst.plans.{ExistenceJoin, LeftSemi}
+import org.apache.spark.sql.catalyst.plans.logical.{BROADCAST, Join, JoinHint,
LogicalPlan, SHUFFLE_HASH, SHUFFLE_MERGE, SHUFFLE_REPLICATE_NL}
+import org.apache.spark.sql.execution.{SparkPlan, SparkStrategy}
+import org.apache.spark.sql.execution.adaptive.{BroadcastQueryStageExec,
LogicalQueryStage}
+import org.apache.spark.sql.execution.joins.{ShuffledHashJoinExec,
SortMergeJoinExec}
+import org.apache.spark.sql.internal.SQLConf
+
+import org.apache.comet.CometConf
+import org.apache.comet.CometSparkSessionExtensions.{isCometLoaded,
withFallbackReason, withInfo}
+import org.apache.comet.shims.ShimJoinSelection
+
+/**
+ * Plans an equi-join as a ShuffledHashJoinExec where Spark would plan a
SortMergeJoinExec, when
+ * spark.comet.exec.forceShuffledHashJoin is on. Choosing the join here,
before EnsureRequirements
+ * and RemoveRedundantSorts run, lets Spark place and keep every sort the plan
needs above the
+ * join. Planner strategies also run again on every AQE re-plan, where the
join's children carry
+ * the materialized shuffle sizes.
+ *
+ * Adapted from the equivalent rule in Apache Gluten.
+ */
+case class CometShuffledHashJoinStrategy(session: SparkSession)
+ extends SparkStrategy
+ with ShimJoinSelection {
+
+ override def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match {
+ case join: Join if isEnabled(join) => planJoin(join)
+ case _ => Nil
+ }
+
+ private def sqlConf: SQLConf = session.sessionState.conf
+
+ // Planner strategies run whether or not Comet is enabled, and before
CometRule, so plan-only
+ // mode needs its own check here.
+ private def isEnabled(join: Join): Boolean =
+ CometConf.COMET_FORCE_SHJ.get(sqlConf) &&
+ CometConf.COMET_EXEC_ENABLED.get(sqlConf) &&
+ !CometConf.COMET_EXPLAIN_PLAN_ONLY_ENABLED.get(sqlConf) &&
+ !join.isStreaming &&
+ isCometLoaded(sqlConf)
+
+ private def planJoin(join: Join): Seq[SparkPlan] = join match {
+ case ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, condition, _,
left, right, hint)
+ if !isLeftToSpark(join, hint) && canSortMergeJoin(joinType) &&
+ RowOrdering.isOrderable(leftKeys) && hashJoinSupportedShim(leftKeys,
rightKeys) =>
+ buildSide(join) match {
+ case Some(BuildRight) if joinType == LeftSemi ||
joinType.isInstanceOf[ExistenceJoin] =>
+ // LeftSemi https://github.com/apache/datafusion-comet/issues/2667
+ // ExistenceJoin
https://github.com/apache/datafusion-comet/issues/2697
+ planBySpark(join) { smj =>
+ withFallbackReason(smj, declineReason(s"BuildRight with $joinType
is not supported"))
+ }
+ case Some(side) =>
+ buildSideOverLimit(join, side) match {
+ case Some(reason) =>
+ // The sort-merge join still runs natively, so this is
information for extended
+ // explain rather than a fallback reason.
+ planBySpark(join)(smj => withInfo(smj, declineReason(reason)))
+ case None =>
+ Seq(
+ ShuffledHashJoinExec(
+ leftKeys,
+ rightKeys,
+ joinType,
+ side,
+ condition,
+ planLater(left),
+ planLater(right)))
+ }
+ case None => Nil
+ }
+ case _ => Nil
+ }
+
+ // Broadcasts, including the ones AQE plans once a stage turns out small,
and joins with a
+ // strategy hint are planned by Spark as usual.
+ private def isLeftToSpark(join: Join, hint: JoinHint): Boolean =
+ isBroadcastStage(join.left) || isBroadcastStage(join.right) ||
+ canPlanAsBroadcastHashJoin(join, sqlConf) || hasStrategyHint(hint)
+
+ private def isBroadcastStage(plan: LogicalPlan): Boolean = plan match {
+ case LogicalQueryStage(_, _: BroadcastQueryStageExec) => true
+ case _ => false
+ }
+
+ // AQE's own hints, such as NO_BROADCAST_HASH and PREFER_SHUFFLE_HASH, do
not count.
+ private def hasStrategyHint(hint: JoinHint): Boolean =
+ Seq(hint.leftHint, hint.rightHint).flatten.flatMap(_.strategy).exists {
+ case BROADCAST | SHUFFLE_MERGE | SHUFFLE_HASH | SHUFFLE_REPLICATE_NL =>
true
+ case _ => false
Review Comment:
Spark already keeps this exact list as `JoinStrategyHint.strategies`
(`BROADCAST`, `SHUFFLE_MERGE`, `SHUFFLE_HASH`, `SHUFFLE_REPLICATE_NL`, without
the AQE-only hints). It is public in `hints.scala` on 3.4.3, 3.5.8, 4.0.1 and
4.1.3, and `ResolveHints` uses it to decide which hint names a user can write.
Could `hasStrategyHint` use it, for example `Seq(hint.leftHint,
hint.rightHint).exists(_.exists(_.strategy.exists(JoinStrategyHint.strategies.contains)))`?
That drops the four constant imports and the match, and walks the hints once
instead of building two intermediate collections. If Spark adds another
user-facing hint later, the join would also keep honoring it without another
edit here. I have not compiled the suggested form.
##########
spark/src/test/scala/org/apache/comet/exec/CometJoinSuite.scala:
##########
@@ -1219,6 +1251,237 @@ class CometJoinSuite extends CometTestBase {
}
}
+ // Spark removes a sort above a sort-merge join whose output ordering
satisfies it, so the
+ // forced hash join must be chosen before that happens or the sort is lost
(#6770).
+ private def withSortLossConf(adaptive: Boolean)(f: => Unit): Unit =
withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> adaptive.toString,
+ SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2",
+ CometConf.COMET_FORCE_SHJ.key -> "true") {
+ withParquetTable((0 until 10000).map(i => (i % 100, i)), "big") {
+ withParquetTable((0 until 10).map(i => (i * 10, i)), "small") {
+ f
+ }
+ }
+ }
+
+ // Checks that a hash join ran, that the rows match Spark's, and that every
partition is sorted
+ // on the first column. A local sort leaves rows with equal keys in any
order, so the rows are
+ // compared without their order.
+ private def checkPartitionsSortedOverHashJoin(df: => DataFrame): Unit = {
+ // withSQLConf returns Unit on Spark 3.x, so the expected rows are
assigned inside it.
+ var expected = Seq.empty[String]
+ withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+ expected = df.collect().map(_.toString).sorted.toSeq
+ }
+ val cometDf = df
+ assert(cometDf.collect().map(_.toString).sorted.toSeq == expected)
+ val cometPlan = cometDf.queryExecution.executedPlan
+ assert(collect(cometPlan) { case j: CometHashJoinExec => j }.nonEmpty,
cometPlan)
+ val sorted = cometDf.queryExecution.toRdd
+ .mapPartitions { it =>
+ val keys = it.map(_.getInt(0)).toArray
+ Iterator(keys.sameElements(keys.sorted))
+ }
+ .collect()
+ assert(sorted.forall(identity), sorted.mkString(", "))
+ }
+
+ private val sortLossJoin =
+ "SELECT big._1 AS k, big._2 AS v FROM big JOIN small ON big._1 = small._1"
+
+ for (adaptive <- Seq(false, true)) {
+ test(s"forceShuffledHashJoin keeps sortWithinPartitions on the join key,
AQE=$adaptive") {
+ withSortLossConf(adaptive) {
+
checkPartitionsSortedOverHashJoin(sql(sortLossJoin).sortWithinPartitions("k"))
+ }
+ }
+
+ test(s"forceShuffledHashJoin keeps SORT BY on the join key,
AQE=$adaptive") {
+ withSortLossConf(adaptive) {
+ checkPartitionsSortedOverHashJoin(sql(s"$sortLossJoin SORT BY k"))
Review Comment:
`sql(sortLossJoin).sortWithinPartitions("k")` and `sql(s"$sortLossJoin SORT
BY k")` should analyze to the same `Sort` with `global = false` over the same
projection and join. In Spark 3.5.8 `AstBuilder` builds `SORT BY` as `Sort(...,
global = false, query)` and `Dataset.sortInternal` builds the same node. From
reading the code I expect identical plans and identical assertions here, but I
have not compared the plans. The issue lists both forms, so I see why both are
here. Would one of them be enough, given that the second adds a run per AQE
setting without a different plan?
--
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]