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

rui-mo 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 757ae0759c [GLUTEN-13176][VL] Pass RESPECT NULLS of collect_set to 
Velox and re-enable two Spark 4.2 tests (#13441)
757ae0759c is described below

commit 757ae0759c3c5452d9e8469933f15f2d71d5b2f9
Author: Akshay Tayal <[email protected]>
AuthorDate: Tue Oct 27 10:21:20 2026 -0700

    [GLUTEN-13176][VL] Pass RESPECT NULLS of collect_set to Velox and re-enable 
two Spark 4.2 tests (#13441)
---
 .../backendsapi/velox/VeloxSparkPlanExecApi.scala  |  4 +-
 .../execution/HashAggregateExecTransformer.scala   | 17 ++++++++-
 .../gluten/extension/CollectRewriteRule.scala      |  3 +-
 .../gluten/utils/VeloxIntermediateData.scala       | 23 +++++++++++-
 .../gluten/utils/velox/VeloxTestSettings.scala     |  8 +---
 .../spark/sql/GlutenDataFrameAggregateSuite.scala  | 32 ++++++++++++++++
 .../spark/sql/GlutenDataFrameFunctionsSuite.scala  | 43 +++++++++++++++++++++-
 .../sql/GlutenDataFrameWindowFunctionsSuite.scala  | 28 ++++++++++++++
 8 files changed, 146 insertions(+), 12 deletions(-)

diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
index 387215ca66..a62fbf849c 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
@@ -28,6 +28,7 @@ import 
org.apache.gluten.shuffle.NeedCustomColumnarBatchSerializer
 import org.apache.gluten.sql.shims.SparkShimLoader
 import org.apache.gluten.substrait.SubstraitContext
 import org.apache.gluten.substrait.expression.{ExpressionBuilder, 
ExpressionNode, WindowFunctionNode}
+import org.apache.gluten.utils.VeloxIntermediateData
 import org.apache.gluten.vectorized.{ColumnarBatchSerializer, 
ColumnarBatchSerializeResult}
 
 import org.apache.spark.{ShuffleDependency, SparkEnv}
@@ -1664,7 +1665,8 @@ class VeloxSparkPlanExecApi extends SparkPlanExecApi with 
Logging {
                   }
               }
 
-            val childrenNodeList = aggregateFunc.children
+            val childrenNodeList = VeloxIntermediateData
+              .getRawInputs(aggregateFunc)
               .map(
                 ExpressionConverter
                   .replaceWithExpressionTransformer(_, originalInputAttributes)
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/execution/HashAggregateExecTransformer.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/execution/HashAggregateExecTransformer.scala
index 8e32c7c9e7..a9d7c42b47 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/execution/HashAggregateExecTransformer.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/execution/HashAggregateExecTransformer.scala
@@ -295,7 +295,11 @@ abstract class HashAggregateExecTransformer(
       val functionInputAttributes = aggFunc.inputAggBufferAttributes
       aggFunc match {
         case _ if aggregateExpression.mode == Partial || 
aggregateExpression.mode == Complete =>
-          val childNodes = aggFunc.children
+          // Literal arguments stay inline in the aggregate (see 
getAggRelInternal), because Velox
+          // only treats an argument as constant when it is a literal, not a 
projected column.
+          val childNodes = VeloxIntermediateData
+            .getRawInputs(aggFunc)
+            .filterNot(_.isInstanceOf[Literal])
             .map(
               ExpressionConverter
                 .replaceWithExpressionTransformer(_, originalInputAttributes)
@@ -504,7 +508,16 @@ abstract class HashAggregateExecTransformer(
         val aggregateFunc = aggExpr.aggregateFunction
         val childrenNodes = aggExpr.mode match {
           case Partial | Complete =>
-            aggregateFunc.children.toList.map(toExpressionNode)
+            // rowConstructed is per operator: in mixed-phase aggregates (e.g. 
the distinct
+            // stage), Partial arguments are also pre-projected and referenced 
by position.
+            // Literals are kept inline so that they reach Velox as constants.
+            VeloxIntermediateData.getRawInputs(aggregateFunc).toList.map {
+              case literal: Literal if rowConstructed =>
+                ExpressionConverter
+                  .replaceWithExpressionTransformer(literal, 
originalInputAttributes)
+                  .doTransform(context)
+              case expr => toExpressionNode(expr)
+            }
           case PartialMerge | Final =>
             if (rowConstructed) {
               // Only occupies one column due to intermediate results are 
combined
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/extension/CollectRewriteRule.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/extension/CollectRewriteRule.scala
index 72e52cf3dd..a7a6a0a906 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/extension/CollectRewriteRule.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/extension/CollectRewriteRule.scala
@@ -76,7 +76,8 @@ object CollectRewriteRule {
     }
   }
 
-  private def getIgnoreNulls(expr: Expression): Boolean = {
+  /** Returns the `ignoreNulls` flag of Spark's CollectSet / CollectList (true 
before Spark 4.2). */
+  def getIgnoreNulls(expr: Expression): Boolean = {
     try {
       val method = expr.getClass.getMethod("ignoreNulls")
       method.invoke(expr).asInstanceOf[Boolean]
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/utils/VeloxIntermediateData.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/utils/VeloxIntermediateData.scala
index a226551525..e82efa76f8 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/utils/VeloxIntermediateData.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/utils/VeloxIntermediateData.scala
@@ -17,8 +17,11 @@
 package org.apache.gluten.utils
 
 import org.apache.gluten.expression.ConverterUtils
+import org.apache.gluten.expression.aggregate.VeloxCollectSet
+import org.apache.gluten.extension.CollectRewriteRule
 import org.apache.gluten.substrait.`type`.{TypeBuilder, TypeNode}
 
+import org.apache.spark.sql.catalyst.expressions.{Expression, Literal}
 import org.apache.spark.sql.catalyst.expressions.aggregate._
 import org.apache.spark.sql.types._
 
@@ -108,6 +111,24 @@ object VeloxIntermediateData {
     }
   }
 
+  /**
+   * Get the arguments passed to a Velox aggregate function when it consumes 
raw input (Partial or
+   * Complete mode).
+   *
+   * @param aggregateFunc
+   *   The input aggregate function.
+   * @return
+   *   The raw input arguments of a Velox aggregate function.
+   */
+  def getRawInputs(aggregateFunc: AggregateFunction): Seq[Expression] = 
aggregateFunc match {
+    // Velox's collect_set takes RESPECT NULLS as a constant `ignoreNulls` 
argument. Window
+    // functions keep Spark's CollectSet, because CollectRewriteRule only 
rewrites aggregates.
+    case c: VeloxCollectSet if !c.ignoreNulls => Seq(c.child, 
Literal.FalseLiteral)
+    case c: CollectSet if !CollectRewriteRule.getIgnoreNulls(c) =>
+      Seq(c.child, Literal.FalseLiteral)
+    case _ => aggregateFunc.children
+  }
+
   /**
    * Get the compatible input types for a Velox aggregate function.
    *
@@ -120,7 +141,7 @@ object VeloxIntermediateData {
    */
   def getInputTypes(aggregateFunc: AggregateFunction, forMergeCompanion: 
Boolean): Seq[DataType] = {
     if (!forMergeCompanion) {
-      return aggregateFunc.children.map(_.dataType)
+      return getRawInputs(aggregateFunc).map(_.dataType)
     }
     aggregateFunc match {
       case _ @Type(veloxDataTypes: Seq[DataType]) =>
diff --git 
a/gluten-ut/spark42/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
 
b/gluten-ut/spark42/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
index adce5b7beb..1eec5810a1 100644
--- 
a/gluten-ut/spark42/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
+++ 
b/gluten-ut/spark42/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
@@ -1014,9 +1014,6 @@ class VeloxTestSettings extends BackendTestSettings {
     .exclude(
       "SPARK-57298: collect_set normalizes NaN and -0.0 nested in complex 
types"
     ) // GLUTEN-12569 Spark 4.2 UT enablement (CI-confirmed)
-    .exclude(
-      "collect_set with RESPECT NULLS preserves null in set"
-    ) // GLUTEN-12569 Spark 4.2 UT enablement (CI-confirmed)
   enableSuite[GlutenDataFrameAsOfJoinSuite]
   enableSuite[GlutenDataFrameComplexTypeSuite]
   enableSuite[GlutenDataFrameFunctionsSuite]
@@ -1027,6 +1024,8 @@ class VeloxTestSettings extends BackendTestSettings {
     .exclude("transform keys function - primitive data types")
     // Overridden.
     .exclude("map with arrays")
+    // Overridden: asserts vanilla WholeStageCodegenExec, which Gluten 
replaces.
+    .exclude("array_join with nullable nullReplacement under whole-stage 
codegen")
     .exclude(
       "SPARK-54918: array set ops normalize -0.0 and NaN via VALUES inline 
table"
     ) // GLUTEN-12569 Spark 4.2 UT enablement (CI-confirmed)
@@ -1048,9 +1047,6 @@ class VeloxTestSettings extends BackendTestSettings {
     .exclude(
       "SPARK-54918: array_union normalizes -0.0 to +0.0 - literals"
     ) // GLUTEN-12569 Spark 4.2 UT enablement (CI-confirmed)
-    .exclude(
-      "array_join with nullable nullReplacement under whole-stage codegen"
-    ) // GLUTEN-12569 Spark 4.2 UT enablement (CI-confirmed)
   enableSuite[GlutenDataFrameHintSuite]
   enableSuite[GlutenDataFrameImplicitsSuite]
   enableSuite[GlutenDataFrameJoinSuite]
diff --git 
a/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameAggregateSuite.scala
 
b/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameAggregateSuite.scala
index a0338f10bc..2326e7ef35 100644
--- 
a/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameAggregateSuite.scala
+++ 
b/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameAggregateSuite.scala
@@ -19,6 +19,8 @@ package org.apache.spark.sql
 import org.apache.gluten.config.GlutenConfig
 import org.apache.gluten.execution.{HashAggregateExecBaseTransformer, 
HashAggregateExecTransformer}
 
+import org.apache.spark.sql.catalyst.expressions.aggregate.{Partial, 
PartialMerge}
+import org.apache.spark.sql.catalyst.optimizer.EliminateDistinct
 import org.apache.spark.sql.execution.WholeStageCodegenExec
 import org.apache.spark.sql.execution.aggregate.{HashAggregateExec, 
ObjectHashAggregateExec, SortAggregateExec}
 import org.apache.spark.sql.execution.exchange.ShuffleExchangeExec
@@ -284,6 +286,36 @@ class GlutenDataFrameAggregateSuite extends 
DataFrameAggregateSuite with GlutenS
     ).foreach(assertNoExceptions)
   }
 
+  testGluten("collect_set RESPECT NULLS in an aggregate that also merges 
another aggregate") {
+    // Without EliminateDistinct, the distinct stage runs collect_set 
(Partial) and avg
+    // (PartialMerge) in one aggregate, which takes the row-construct path.
+    withSQLConf(SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> 
EliminateDistinct.ruleName) {
+      withTempView("t") {
+        Seq((1, Some(2)), (2, None), (3, Some(2)), (4, Some(5))).toDF("a", "b")
+          .createOrReplaceTempView("t")
+        val df = sql("SELECT avg(a), sort_array(collect_set(DISTINCT b) 
RESPECT NULLS) FROM t")
+        checkAnswer(df, Row(2.5, Seq(null, 2, 5)))
+        val plan = df.queryExecution.executedPlan
+        assert(
+          collect(plan) {
+            case agg: HashAggregateExecTransformer
+                if agg.aggregateExpressions.exists(_.mode == PartialMerge) &&
+                  agg.aggregateExpressions.exists(_.mode == Partial) =>
+              agg
+          }.nonEmpty,
+          plan
+        )
+        assert(
+          collect(plan) {
+            case agg @ (_: HashAggregateExec | _: ObjectHashAggregateExec |
+                _: SortAggregateExec) =>
+              agg
+          }.isEmpty,
+          plan)
+      }
+    }
+  }
+
   testGluten("SPARK-22223: ObjectHashAggregate should not introduce 
unnecessary shuffle") {
     withSQLConf(SQLConf.USE_OBJECT_HASH_AGG.key -> "true") {
       val df = Seq(("1", "2", 1), ("1", "2", 2), ("2", "3", 3), ("2", "3", 4))
diff --git 
a/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameFunctionsSuite.scala
 
b/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameFunctionsSuite.scala
index 40ac1edd60..5786921364 100644
--- 
a/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameFunctionsSuite.scala
+++ 
b/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameFunctionsSuite.scala
@@ -17,13 +17,19 @@
 package org.apache.spark.sql
 
 import org.apache.gluten.exception.GlutenException
+import org.apache.gluten.execution.ProjectExecTransformer
 
 import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.ArrayJoin
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
 import org.apache.spark.sql.functions._
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.{IntegerType, MapType, StringType, 
StructField, StructType}
 
-class GlutenDataFrameFunctionsSuite extends DataFrameFunctionsSuite with 
GlutenSQLTestsTrait {
+class GlutenDataFrameFunctionsSuite
+  extends DataFrameFunctionsSuite
+  with GlutenSQLTestsTrait
+  with AdaptiveSparkPlanHelper {
   import testImplicits._
 
   testGluten("map with arrays") {
@@ -409,4 +415,39 @@ class GlutenDataFrameFunctionsSuite extends 
DataFrameFunctionsSuite with GlutenS
       oneRowDF.selectExpr("flatten(null)")
     }
   }
+
+  testGluten("array_join with nullable nullReplacement under whole-stage 
codegen") {
+    withTempView("array_join_codegen") {
+      Seq(
+        (Seq[String]("a", null, "b"), ",", "NR"),
+        (Seq[String]("a", null, "b"), ",", null),
+        (Seq[String]("x", "y"), "-", "NR")
+      ).toDF("arr", "delim_col", 
"repl_col").createOrReplaceTempView("array_join_codegen")
+      spark.catalog.cacheTable("array_join_codegen")
+
+      val query =
+        "SELECT array_join(arr, delim_col, repl_col) FROM array_join_codegen " 
+
+          "WHERE arr IS NOT NULL AND delim_col IS NOT NULL"
+
+      withSQLConf(SQLConf.CODEGEN_FACTORY_MODE.key -> "CODEGEN_ONLY") {
+        val df = sql(query)
+        // Gluten offloads array_join instead of running it in 
WholeStageCodegenExec.
+        assert(
+          find(df.queryExecution.executedPlan) {
+            case p: ProjectExecTransformer =>
+              p.projectList.exists(_.find(_.isInstanceOf[ArrayJoin]).isDefined)
+            case _ => false
+          }.isDefined,
+          "expected array_join to be offloaded"
+        )
+        checkAnswer(df, Seq(Row("a,NR,b"), Row(null), Row("x-y")))
+      }
+
+      withSQLConf(
+        SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "false",
+        SQLConf.CODEGEN_FACTORY_MODE.key -> "NO_CODEGEN") {
+        checkAnswer(sql(query), Seq(Row("a,NR,b"), Row(null), Row("x-y")))
+      }
+    }
+  }
 }
diff --git 
a/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameWindowFunctionsSuite.scala
 
b/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameWindowFunctionsSuite.scala
index 213c4c1aec..46bb0be76d 100644
--- 
a/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameWindowFunctionsSuite.scala
+++ 
b/gluten-ut/spark42/src/test/scala/org/apache/spark/sql/GlutenDataFrameWindowFunctionsSuite.scala
@@ -23,6 +23,7 @@ import 
org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression
 import org.apache.spark.sql.catalyst.plans.physical.HashPartitioning
 import org.apache.spark.sql.execution.ColumnarShuffleExchangeExec
 import org.apache.spark.sql.execution.exchange.ENSURE_REQUIREMENTS
+import org.apache.spark.sql.execution.window.WindowExec
 import org.apache.spark.sql.expressions.Window
 import org.apache.spark.sql.functions._
 import org.apache.spark.sql.internal.SQLConf
@@ -206,4 +207,31 @@ class GlutenDataFrameWindowFunctionsSuite
       assert(shuffleByRequirement, "Can't find desired shuffle node from the 
query plan")
     }
   }
+
+  testGluten("collect_set RESPECT NULLS over a window frame") {
+    withTempView("t") {
+      Seq((1, Some(2)), (2, None), (3, Some(2)), (4, Some(5))).toDF("a", "b")
+        .createOrReplaceTempView("t")
+      val df = sql(
+        """
+          |SELECT a,
+          |  sort_array(collect_set(b) RESPECT NULLS
+          |    OVER (ORDER BY a ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)),
+          |  sort_array(collect_set(b) RESPECT NULLS OVER (ORDER BY a)),
+          |  sort_array(collect_set(b) OVER (ORDER BY a ROWS BETWEEN 1 
PRECEDING AND CURRENT ROW))
+          |FROM t""".stripMargin)
+      checkAnswer(
+        df,
+        Seq(
+          Row(1, Seq(2), Seq(2), Seq(2)),
+          Row(2, Seq(null, 2), Seq(null, 2), Seq(2)),
+          Row(3, Seq(null, 2), Seq(null, 2), Seq(2)),
+          Row(4, Seq(2, 5), Seq(null, 2, 5), Seq(2, 5))
+        )
+      )
+      val plan = df.queryExecution.executedPlan
+      assert(collect(plan) { case w: WindowExecTransformer => w }.nonEmpty, 
plan)
+      assert(collect(plan) { case w: WindowExec => w }.isEmpty, plan)
+    }
+  }
 }


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

Reply via email to