Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr_build_linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ jobs:
org.apache.comet.rules.CometScanRuleSuite
org.apache.comet.rules.CometScanSchemeFallbackSuite
org.apache.comet.rules.CometExecRuleSuite
org.apache.comet.rules.RevertNativeForTransitionHeavyStagesSuite
org.apache.spark.sql.CometTPCDSQuerySuite
org.apache.spark.sql.CometTPCDSQueryTestSuite
org.apache.spark.sql.CometTPCHQuerySuite
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr_build_macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ jobs:
org.apache.comet.rules.CometScanRuleSuite
org.apache.comet.rules.CometScanSchemeFallbackSuite
org.apache.comet.rules.CometExecRuleSuite
org.apache.comet.rules.RevertNativeForTransitionHeavyStagesSuite
org.apache.spark.sql.CometTPCDSQuerySuite
org.apache.spark.sql.CometTPCDSQueryTestSuite
org.apache.spark.sql.CometTPCHQuerySuite
Expand Down
26 changes: 26 additions & 0 deletions spark/src/main/scala/org/apache/comet/CometConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,32 @@ object CometConf extends ShimCometConf {
.booleanConf
.createWithDefault(true)

val COMET_EXEC_TRANSITION_REVERT_ENABLED: ConfigEntry[Boolean] =
conf(s"$COMET_EXEC_CONFIG_PREFIX.transitionRevert.enabled")
.category(CATEGORY_EXEC)
.doc(
"When enabled, Comet reverts a query stage to Spark row-based execution if the number " +
"of columnar-to-row (C2R) transitions in the stage exceeds the configured threshold. " +
"This avoids the overhead of repeated format conversions in stages where many " +
"operators fall back to row-based execution.")
.booleanConf
.createWithDefault(false)

val COMET_EXEC_TRANSITION_REVERT_MAX_TRANSITIONS: ConfigEntry[Int] =
conf(s"$COMET_EXEC_CONFIG_PREFIX.transitionRevert.maxTransitions")
.category(CATEGORY_EXEC)
.doc(
"The maximum number of columnar-to-row (C2R) transitions allowed in a single query " +
"stage before Comet reverts the entire stage to Spark row-based execution. When " +
"columnar shuffle is enabled, each such C2R typically implies a corresponding " +
"row-to-columnar conversion to feed back into the columnar shuffle, so each counted " +
"C2R is a useful proxy for the conversion overhead in the stage. Set to 0 to revert " +
"any stage with transitions. " +
"Only effective when spark.comet.exec.transitionRevert.enabled is true.")
.intConf
.checkValue(_ >= 0, "Must be >= 0.")
.createWithDefault(2)

val COMET_EXEC_SHUFFLE_COMPRESSION_CODEC: ConfigEntry[String] =
conf(s"$COMET_EXEC_CONFIG_PREFIX.shuffle.compression.codec")
.category(CATEGORY_SHUFFLE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import org.apache.spark.sql.execution._
import org.apache.spark.sql.internal.SQLConf

import org.apache.comet.CometConf._
import org.apache.comet.rules.{CometExecRule, CometPlanAdaptiveDynamicPruningFilters, CometReuseSubquery, CometScanRule, CometSpark34AqeDppFallbackRule, EliminateRedundantTransitions}
import org.apache.comet.rules.{CometExecRule, CometPlanAdaptiveDynamicPruningFilters, CometReuseSubquery, CometScanRule, CometSpark34AqeDppFallbackRule, EliminateRedundantTransitions, RevertNativeForTransitionHeavyStages}
import org.apache.comet.shims.ShimCometSparkSessionExtensions

/**
Expand All @@ -51,7 +51,8 @@ import org.apache.comet.shims.ShimCometSparkSessionExtensions
* - CometExecRule.convertSubqueryBroadcasts converts SubqueryBroadcastExec to
* CometSubqueryBroadcastExec for exchange reuse with Comet broadcasts
* b. insertTransitions: ColumnarToRow/RowToColumnar added
* c. postColumnarTransitions: EliminateRedundantTransitions
* c. postColumnarTransitions: RevertNativeForTransitionHeavyStages,
* EliminateRedundantTransitions
* 5. ReuseExchangeAndSubquery -- Spark deduplicates subqueries (sees Comet nodes)
* }}}
*
Expand All @@ -74,7 +75,8 @@ import org.apache.comet.shims.ShimCometSparkSessionExtensions
* 2. postStageCreationRules -> ApplyColumnarRulesAndInsertTransitions:
* a. preColumnarTransitions: CometScanRule, CometExecRule (no-ops, already converted)
* b. insertTransitions
* c. postColumnarTransitions: EliminateRedundantTransitions
* c. postColumnarTransitions: RevertNativeForTransitionHeavyStages,
* EliminateRedundantTransitions
* }}}
*
* On Spark 3.4, injectQueryStageOptimizerRule is unavailable. CometExecRule does not wrap SABs,
Expand Down Expand Up @@ -106,8 +108,11 @@ class CometSparkSessionExtensions
case class CometExecColumnar(session: SparkSession) extends ColumnarRule {
override def preColumnarTransitions: Rule[SparkPlan] = CometExecRule(session)

override def postColumnarTransitions: Rule[SparkPlan] =
EliminateRedundantTransitions(session)
override def postColumnarTransitions: Rule[SparkPlan] = {
val rules =
Seq(RevertNativeForTransitionHeavyStages(session), EliminateRedundantTransitions(session))
plan => rules.foldLeft(plan) { case (p, rule) => rule(p) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* 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.internal.Logging
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.comet.{CometColumnarToRowExec, CometExec, CometNativeColumnarToRowExec, CometSparkToColumnarExec}
import org.apache.spark.sql.execution.{ColumnarToRowExec, ColumnarToRowTransition, RowToColumnarExec, SparkPlan}
import org.apache.spark.sql.execution.adaptive.QueryStageExec
import org.apache.spark.sql.execution.exchange.{BroadcastExchangeLike, ShuffleExchangeLike}

import org.apache.comet.CometConf
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason

/**
* Reverts a query stage to Spark row-based execution when it has too many columnar-to-row (C2R)
* transitions. Each C2R indicates Comet could not keep execution columnar and had to fall back.
* With columnar shuffle enabled, each C2R implies a corresponding R2C round-trip.
*/
case class RevertNativeForTransitionHeavyStages(session: SparkSession)
extends Rule[SparkPlan]
with Logging {

private def enabled = CometConf.COMET_EXEC_TRANSITION_REVERT_ENABLED.get()
private def maxTransitions = CometConf.COMET_EXEC_TRANSITION_REVERT_MAX_TRANSITIONS.get()

override def apply(plan: SparkPlan): SparkPlan = {
if (!enabled) return plan

if (session.sessionState.conf.adaptiveExecutionEnabled) {
applyForAQE(plan)
} else {
applyForNonAQE(plan)
}
}

private def applyForAQE(plan: SparkPlan): SparkPlan = {
plan match {
case _: BroadcastExchangeLike => plan
case exchange: ShuffleExchangeLike =>
revertStageIfNeeded(exchange.child, exchange.supportsColumnar)
.map(reverted => exchange.withNewChildren(Seq(reverted)))
.getOrElse(plan)
case _ =>
// Result stage: its output is collected as rows, so no consumer requires columnar input
// and the reverted stage needs no trailing R2C.
revertStageIfNeeded(plan, outputColumnar = false).getOrElse(plan)
}
}

private def applyForNonAQE(plan: SparkPlan): SparkPlan = {
val withRevertedStages = plan.transformUp { case exchange: ShuffleExchangeLike =>
revertStageIfNeeded(exchange.child, exchange.supportsColumnar)
.map(reverted => exchange.withNewChildren(Seq(reverted)))
.getOrElse(exchange)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there no case _ => arm like in the AQE case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handles the final stage, since case match on Exchnage above(using transformUp) cannot catch it, which is the equivalent of case _ (ResultStage) in AQE flow

}
revertStageIfNeeded(withRevertedStages, outputColumnar = false)
.getOrElse(withRevertedStages)
}

/**
* Reverts the stage if C2R count exceeds threshold. Wraps in R2C if exchange needs columnar.
*/
private def revertStageIfNeeded(
stagePlan: SparkPlan,
outputColumnar: Boolean): Option[SparkPlan] = {
val transitionCount = countTransitions(stagePlan)
if (transitionCount <= maxTransitions) return None

@comphead comphead Jun 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make a user friendly response it would be better to use withFallbackReason instead of logging. Example CometExecRule.scala

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


val reason =
s"Stage reverted: $transitionCount C2R transitions exceed threshold $maxTransitions"

val reverted = revertToSpark(stagePlan)
val result = if (outputColumnar && !reverted.supportsColumnar) {
RowToColumnarExec(withFallbackReason(reverted, reason))
} else {
withFallbackReason(reverted, reason)
}
Some(result)
}

/**
* A node that marks the boundary between this stage and an adjacent one.
*/
private def isStageBoundary(plan: SparkPlan): Boolean = plan match {
case _: QueryStageExec | _: ShuffleExchangeLike | _: BroadcastExchangeLike => true
case _ => false
}

/**
* Like `transformDown`, never descends stage-boundary children.
*/
private def transformStageDown(plan: SparkPlan)(
rule: PartialFunction[SparkPlan, SparkPlan]): SparkPlan = {
val transformed = rule.applyOrElse(plan, identity[SparkPlan])
val newChildren = transformed.children.map { child =>
if (isStageBoundary(child)) child else transformStageDown(child)(rule)
}
if (newChildren == transformed.children) transformed
else transformed.withNewChildren(newChildren)
}

/** Like `transformUp`, never descends stage-boundary children. */
private def transformStageUp(plan: SparkPlan)(
rule: PartialFunction[SparkPlan, SparkPlan]): SparkPlan = {
val newChildren = plan.children.map { child =>
if (isStageBoundary(child)) child else transformStageUp(child)(rule)
}
val withNewChildren =
if (newChildren == plan.children) plan else plan.withNewChildren(newChildren)
rule.applyOrElse(withNewChildren, identity[SparkPlan])
}

/** Counts C2R transitions within this stage, stopping at stage boundaries. */
private[rules] def countTransitions(plan: SparkPlan): Int = {
var count = 0
def visit(node: SparkPlan): Unit = node match {
case _ if isStageBoundary(node) => ()
case _: ColumnarToRowTransition =>
count += 1
node.children.foreach(visit)
case _ =>
node.children.foreach(visit)
}
visit(plan)
count
}

private[rules] def revertToSpark(plan: SparkPlan): SparkPlan = {
val stripped = transformStageDown(plan) {
case CometNativeColumnarToRowExec(child) => child
case CometColumnarToRowExec(child) => child
case ColumnarToRowExec(child) => child
case sparkToColumnar: CometSparkToColumnarExec => sparkToColumnar.child
case RowToColumnarExec(child) => child
}
val reverted = transformStageUp(stripped) { case cometExec: CometExec =>
if (cometExec.originalPlan.children.size == cometExec.children.size) {
cometExec.originalPlan.withNewChildren(cometExec.children)
} else {
logWarning(
"Comet plan and original have different child count for " +
s"${cometExec.getClass.getSimpleName}, using originalPlan as-is.")
cometExec.originalPlan
}
}
insertTransitions(reverted)
}

private def insertTransitions(plan: SparkPlan): SparkPlan = {
// transformStageUp never descends into stage-boundary nodes (QueryStageExec, exchanges), so
// this only needs to bridge row nodes that still have a columnar child within the stage.
transformStageUp(plan) {
case node if !node.supportsColumnar =>
val newChildren = node.children.map { child =>
if (child.supportsColumnar) ColumnarToRowExec(child) else child
Comment thread
parthchandra marked this conversation as resolved.
}
if (newChildren != node.children) node.withNewChildren(newChildren) else node
}
}
}
Comment thread
parthchandra marked this conversation as resolved.
Loading