This is an automated email from the ASF dual-hosted git repository.

zhztheplayer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 223fd820b6 [VL] Introduce config to allow fail on fallback (#12279)
223fd820b6 is described below

commit 223fd820b6d74962fd9e9d3fef75ba36acc9474a
Author: Felix Loesing <[email protected]>
AuthorDate: Wed Jun 17 01:11:48 2026 -0700

    [VL] Introduce config to allow fail on fallback (#12279)
---
 .../org/apache/gluten/config/GlutenConfig.scala    | 11 ++++
 .../spark/sql/execution/GlutenExplainUtils.scala   | 61 ++++++++++++++-----
 .../sql/execution/GlutenFallbackReporter.scala     | 68 ++++++++++++++-------
 .../execution/GlutenFailOnFallbackSuite.scala      | 70 ++++++++++++++++++++++
 4 files changed, 172 insertions(+), 38 deletions(-)

diff --git 
a/gluten-substrait/src/main/scala/org/apache/gluten/config/GlutenConfig.scala 
b/gluten-substrait/src/main/scala/org/apache/gluten/config/GlutenConfig.scala
index f32f225be6..fad32a0489 100644
--- 
a/gluten-substrait/src/main/scala/org/apache/gluten/config/GlutenConfig.scala
+++ 
b/gluten-substrait/src/main/scala/org/apache/gluten/config/GlutenConfig.scala
@@ -325,6 +325,8 @@ class GlutenConfig(conf: SQLConf) extends 
GlutenCoreConfig(conf) {
 
   def enableFallbackReport: Boolean = getConf(FALLBACK_REPORTER_ENABLED)
 
+  def failOnFallback: Boolean = getConf(FALLBACK_FAIL_ON_FALLBACK)
+
   def debug: Boolean = getConf(DEBUG_ENABLED)
 
   /** Full scan SQL metrics; also enabled when [[debug]] is true. */
@@ -1430,6 +1432,15 @@ object GlutenConfig extends ConfigRegistry {
       .booleanConf
       .createWithDefault(true)
 
+  val FALLBACK_FAIL_ON_FALLBACK =
+    buildConf("spark.gluten.sql.columnar.failOnFallback")
+      .internal()
+      .doc(
+        "When true, throw an exception if any operator falls back to Spark" +
+          " instead of running on the native engine.")
+      .booleanConf
+      .createWithDefault(false)
+
   val TEXT_INPUT_ROW_MAX_BLOCK_SIZE =
     buildConf("spark.gluten.sql.text.input.max.block.size")
       .doc("the max block size for text input rows")
diff --git 
a/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenExplainUtils.scala
 
b/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenExplainUtils.scala
index dd115efa54..4e15b3e09d 100644
--- 
a/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenExplainUtils.scala
+++ 
b/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenExplainUtils.scala
@@ -84,18 +84,39 @@ object GlutenExplainUtils extends AdaptiveSparkPlanHelper {
     }
   }
 
-  private def collectFallbackNodes(plan: QueryPlan[_]): FallbackInfo = {
-    var numGlutenNodes = 0
-    val fallbackNodeToReason = new mutable.HashMap[String, String]
+  /**
+   * Walks `plan` and classifies each operator into one of three buckets:
+   *   - native Gluten operator: invokes `onGluten`
+   *   - vanilla Spark operator considered a fallback: invokes `onFallback` 
with the reason resolved
+   *     from physical or logical [[FallbackTags]], or a synthetic default 
when no tag is present
+   *   - structural / non-operator nodes (commands, transitions, codegen 
wrappers, query stages, AQE
+   *     shuffle reads, reused exchanges, etc.): no callback
+   *
+   * Recurses into AQE subqueries and query stages, and into each visited 
operator's
+   * `innerChildren`. Subqueries reached purely via expressions on a vanilla 
Spark plan are not
+   * visited here; callers that need full subquery coverage should use 
[[processPlan]], which
+   * performs an explicit subquery walk.
+   */
+  def visitFallbackNodes(
+      plan: QueryPlan[_],
+      onFallback: (SparkPlan, String) => Unit,
+      onGluten: SparkPlan => Unit = _ => ()): Unit = {
 
-    def collect(tmp: QueryPlan[_]): Unit = {
+    def fallbackReason(p: SparkPlan): String =
+      FallbackTags
+        .getOption(p)
+        .orElse(p.logicalLink.flatMap(FallbackTags.getOption))
+        .map(_.reason())
+        .getOrElse("Gluten does not touch it or does not support it")
+
+    def visit(tmp: QueryPlan[_]): Unit = {
       tmp.foreachUp {
         case _: ExecutedCommandExec =>
-        case cmd: CommandResultExec => collect(cmd.commandPhysicalPlan)
+        case cmd: CommandResultExec => visit(cmd.commandPhysicalPlan)
         case p: V2CommandExec
             if FallbackTags.nonEmpty(p) ||
               p.logicalLink.exists(FallbackTags.getOption(_).nonEmpty) =>
-          handleVanillaSparkPlan(p, fallbackNodeToReason)
+          onFallback(p, fallbackReason(p))
         case _: V2CommandExec =>
         case _: DataWritingCommandExec =>
         case _: WholeStageCodegenExec =>
@@ -108,26 +129,36 @@ object GlutenExplainUtils extends AdaptiveSparkPlanHelper 
{
         case _: ReusedExchangeExec =>
         case _: NoopLeaf =>
         case w: WriteFilesExec if w.child.isInstanceOf[NoopLeaf] =>
-        case sub: AdaptiveSparkPlanExec if sub.isSubquery => 
collect(sub.executedPlan)
+        case sub: AdaptiveSparkPlanExec if sub.isSubquery => 
visit(sub.executedPlan)
         case _: AdaptiveSparkPlanExec =>
-        case p: QueryStageExec => collect(p.plan)
+        case p: QueryStageExec => visit(p.plan)
         case p: GlutenPlan =>
-          numGlutenNodes += 1
-          p.innerChildren.foreach(collect)
+          onGluten(p)
+          p.innerChildren.foreach(visit)
         case i: InMemoryTableScanExec =>
           if (PlanUtil.isGlutenTableCache(i)) {
-            numGlutenNodes += 1
+            onGluten(i)
           } else {
-            addFallbackNodeWithReason(i, "Columnar table cache is disabled", 
fallbackNodeToReason)
+            onFallback(i, "Columnar table cache is disabled")
           }
         case _: AQEShuffleReadExec => // Ignore
         case p: SparkPlan =>
-          handleVanillaSparkPlan(p, fallbackNodeToReason)
-          p.innerChildren.foreach(collect)
+          onFallback(p, fallbackReason(p))
+          p.innerChildren.foreach(visit)
         case _ =>
       }
     }
-    collect(plan)
+    visit(plan)
+  }
+
+  private def collectFallbackNodes(plan: QueryPlan[_]): FallbackInfo = {
+    var numGlutenNodes = 0
+    val fallbackNodeToReason = new mutable.HashMap[String, String]
+    visitFallbackNodes(
+      plan,
+      onGluten = _ => numGlutenNodes += 1,
+      onFallback = (p, reason) => addFallbackNodeWithReason(p, reason, 
fallbackNodeToReason)
+    )
     (numGlutenNodes, fallbackNodeToReason.toMap)
   }
 
diff --git 
a/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenFallbackReporter.scala
 
b/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenFallbackReporter.scala
index 0ef0b6a28c..920b8b71b5 100644
--- 
a/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenFallbackReporter.scala
+++ 
b/gluten-substrait/src/main/scala/org/apache/spark/sql/execution/GlutenFallbackReporter.scala
@@ -18,7 +18,7 @@ package org.apache.spark.sql.execution
 
 import org.apache.gluten.config.GlutenConfig
 import org.apache.gluten.events.GlutenPlanFallbackEvent
-import org.apache.gluten.execution.GlutenPlan
+import org.apache.gluten.exception.GlutenException
 import org.apache.gluten.extension.columnar.FallbackTags
 import org.apache.gluten.logging.LogLevelUtil
 
@@ -27,20 +27,33 @@ import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.catalyst.util.StringUtils.PlanStringConcat
 import org.apache.spark.sql.execution.ui.GlutenUIUtils
 
+import scala.collection.mutable.ArrayBuffer
+
 /**
- * This rule is used to collect all fallback reason.
- *   1. print fallback reason for each plan node 2. post all fallback reason 
using one event
+ * Collects fallback information from a finalized columnar plan and, depending 
on configuration:
+ *   1. logs the fallback reason for every non-Gluten operator 2. throws a 
[[GlutenException]] when
+ *      `spark.gluten.sql.columnar.failOnFallback` is enabled and any fallback 
is present 3. posts a
+ *      single [[GlutenPlanFallbackEvent]] for the SQL UI
+ *
+ * The set of operators that count as a fallback is shared with 
[[GlutenExplainUtils]] via
+ * [[GlutenExplainUtils.visitFallbackNodes]], so the reporter, the SQL UI, and 
any future consumers
+ * always agree on what "fallback" means.
  */
 case class GlutenFallbackReporter(glutenConf: GlutenConfig, spark: 
SparkSession)
   extends Rule[SparkPlan]
   with LogLevelUtil {
 
   override def apply(plan: SparkPlan): SparkPlan = {
-    if (!glutenConf.enableFallbackReport) {
+    val report = glutenConf.enableFallbackReport
+    val fail = glutenConf.failOnFallback
+    if (!report && !fail) {
       return plan
     }
-    printFallbackReason(plan)
-    if (GlutenUIUtils.uiEnabled(spark.sparkContext)) {
+    val fallbacks = collectAndLogFallbacks(plan, log = report)
+    if (fail) {
+      throwIfFallback(fallbacks)
+    }
+    if (report && GlutenUIUtils.uiEnabled(spark.sparkContext)) {
       postFallbackReason(plan)
     }
     plan
@@ -53,26 +66,35 @@ case class GlutenFallbackReporter(glutenConf: GlutenConfig, 
spark: SparkSession)
     logOnLevel(logLevel, s"Validation failed for plan: 
$nodeName$executionIdInfo, due to: $reason")
   }
 
-  private def printFallbackReason(plan: SparkPlan): Unit = {
+  private def collectAndLogFallbacks(plan: SparkPlan, log: Boolean): 
Seq[(String, String)] = {
     val validationLogLevel = glutenConf.validationLogLevel
-    def printPlan(p: SparkPlan): Unit = {
-      p.foreachUp {
-        case _: GlutenPlan => // ignore
-        case cmd: CommandResultExec =>
-          printPlan(cmd.commandPhysicalPlan)
-        case p: SparkPlan if FallbackTags.nonEmpty(p) =>
-          val tag = FallbackTags.get(p)
-          logFallbackReason(validationLogLevel, p.nodeName, tag.reason())
-          // With in next round stage in AQE, the physical plan would be a new 
instance that
-          // can not preserve the tag, so we need to set the fallback reason 
to logical plan.
-          // Then we can be aware of the fallback reason for the whole plan.
-          // If a logical plan mapping to several physical plan, we add all 
reason into
-          // that logical plan to make sure we do not lose any fallback reason.
-          p.logicalLink.foreach(logicalPlan => FallbackTags.add(logicalPlan, 
tag))
-        case _ =>
+    val fallbacks = ArrayBuffer[(String, String)]()
+    GlutenExplainUtils.visitFallbackNodes(
+      plan,
+      onFallback = (node, reason) => {
+        if (log) {
+          logFallbackReason(validationLogLevel, node.nodeName, reason)
+          // Within the next AQE stage, the physical plan would be a new 
instance that does not
+          // preserve the tag, so we propagate the fallback reason to the 
logical plan.
+          // If a logical plan maps to several physical plans, we add all 
reasons onto that
+          // logical plan to make sure we do not lose any fallback reason.
+          FallbackTags
+            .getOption(node)
+            .foreach(tag => node.logicalLink.foreach(FallbackTags.add(_, tag)))
+        }
+        fallbacks += ((node.nodeName, reason))
       }
+    )
+    fallbacks.toSeq
+  }
+
+  private def throwIfFallback(fallbacks: Seq[(String, String)]): Unit = {
+    if (fallbacks.nonEmpty) {
+      val reasons = fallbacks.map { case (name, reason) => s"  $name: $reason" 
}.mkString("\n")
+      throw new GlutenException(
+        s"Gluten fallback detected 
(${GlutenConfig.FALLBACK_FAIL_ON_FALLBACK.key} is enabled). " +
+          s"${fallbacks.size} operator(s) fell back to Spark:\n$reasons")
     }
-    printPlan(plan)
   }
 
   private def postFallbackReason(plan: SparkPlan): Unit = {
diff --git 
a/gluten-ut/test/src/test/scala/org/apache/gluten/execution/GlutenFailOnFallbackSuite.scala
 
b/gluten-ut/test/src/test/scala/org/apache/gluten/execution/GlutenFailOnFallbackSuite.scala
new file mode 100644
index 0000000000..b05b6c952e
--- /dev/null
+++ 
b/gluten-ut/test/src/test/scala/org/apache/gluten/execution/GlutenFailOnFallbackSuite.scala
@@ -0,0 +1,70 @@
+/*
+ * 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.gluten.execution
+
+import org.apache.gluten.config.GlutenConfig
+import org.apache.gluten.exception.GlutenException
+import org.apache.gluten.utils.BackendTestUtils
+
+import org.apache.spark.SparkConf
+
+import java.util.Locale
+
+class GlutenFailOnFallbackSuite extends WholeStageTransformerSuite {
+
+  protected val resourcePath: String = null
+  protected val fileFormat: String = null
+
+  override protected def sparkConf: SparkConf = {
+    val conf = super.sparkConf
+    if (BackendTestUtils.isCHBackendLoaded()) {
+      conf.set(GlutenConfig.NATIVE_VALIDATION_ENABLED.key, "false")
+    }
+    conf
+  }
+
+  test("failOnFallback throws only when a fallback is present") {
+    withTable("t") {
+      spark.range(10).write.format("parquet").saveAsTable("t")
+
+      // failOnFallback explicitly disabled: a fallback must NOT throw.
+      withSQLConf(
+        GlutenConfig.FALLBACK_FAIL_ON_FALLBACK.key -> "false",
+        GlutenConfig.COLUMNAR_FILESCAN_ENABLED.key -> "false") {
+        sql("SELECT * FROM t").collect()
+      }
+
+      // failOnFallback enabled, fully native query (no fallback): must NOT 
throw.
+      withSQLConf(GlutenConfig.FALLBACK_FAIL_ON_FALLBACK.key -> "true") {
+        sql("SELECT * FROM t").collect()
+      }
+
+      // failOnFallback enabled, fallback present: must throw with the config 
key
+      // and the offending operator/reason in the message.
+      withSQLConf(
+        GlutenConfig.FALLBACK_FAIL_ON_FALLBACK.key -> "true",
+        GlutenConfig.COLUMNAR_FILESCAN_ENABLED.key -> "false") {
+        val ex = intercept[GlutenException] {
+          sql("SELECT * FROM t").collect()
+        }
+        
assert(ex.getMessage.contains(GlutenConfig.FALLBACK_FAIL_ON_FALLBACK.key))
+        assert(ex.getMessage.contains("fell back to Spark"))
+        assert(ex.getMessage.toLowerCase(Locale.ROOT).contains("scan"))
+      }
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to