This is an automated email from the ASF dual-hosted git repository.
weiting-chen 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 ea80387689 [VL] Add RDDScanExec support to Velox backend (#12982)
ea80387689 is described below
commit ea80387689cb35746bcd7dc94ee68e034f6e04e4
Author: Minni Mittal <[email protected]>
AuthorDate: Fri Sep 11 11:21:54 2026 +0530
[VL] Add RDDScanExec support to Velox backend (#12982)
* [VL] Add RDDScanExec support to Velox backend
Offload RDDScanExec to the Velox backend by converting the underlying
RDD[InternalRow] into columnar batches through the native
row-to-columnar path (the same JNI path used by RowToVeloxColumnarExec).
Highlights:
- New VeloxRDDScanTransformer implementing the RDDScanTransformer
contract. Schemas not supported by the Arrow export path (e.g. map or
interval types) are rejected in validation and fall back to vanilla
Spark.
- Wire isSupportRDDScanExec / getRDDScanTransform in VeloxSparkPlanExecApi.
Offload is gated by a new config and skipped inside a Structured
Streaming query (micro-batch / foreachBatch), where offloading a
materialized per-batch source into a state-store pipeline can deadlock.
- New config spark.gluten.sql.columnar.backend.velox.rddScan.enabled
(default true), which also acts as a runtime kill-switch.
- Nullability-aware row conversion: for schemas with non-nullable fields,
rows are pre-projected with a nullability-honoring UnsafeProjection so a
null in a non-nullable field yields the type default, matching Spark's
WholeStageCodegen behavior (SPARK-35912). The guard recurses into nested
struct/array/map types.
- Guard ColumnarPartialProjectExec against an empty projectAttributes set
(a projection that references no child column), which can arise once
RDDScan feeds downstream partial projection.
- Add VeloxRDDScanSuite covering type coverage, aggregation downstream,
empty/duplicate reads, null handling, checkpoint BatchCarrierRow reuse,
map/interval fallback, nullability coercion, kill-switch fallback, and
the streaming-query skip.
Co-authored-by: Copilot <[email protected]>
* [VL] Fix RDDScan CI regressions
Reject zero-column RDDScan schemas so Spark's OneRowRelation remains on the
row path, preserving existing plan assertions and parameterized EXPLAIN
output.
Add a regression test for the fallback and regenerate the Velox
configuration
documentation for the RDDScan setting.
Co-authored-by: Copilot <[email protected]>
---------
Co-authored-by: Copilot <[email protected]>
---
.../backendsapi/velox/VeloxSparkPlanExecApi.scala | 36 ++
.../org/apache/gluten/config/VeloxConfig.scala | 12 +
.../execution/ColumnarPartialProjectExec.scala | 4 +
.../gluten/execution/VeloxRDDScanTransformer.scala | 225 ++++++++++++
.../spark/sql/execution/VeloxRDDScanSuite.scala | 378 +++++++++++++++++++++
docs/velox-configuration.md | 1 +
6 files changed, 656 insertions(+)
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 c00803c521..5e3a88941a 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
@@ -37,6 +37,7 @@ import org.apache.spark.memory.SparkMemoryUtil
import org.apache.spark.rdd.RDD
import org.apache.spark.serializer.Serializer
import org.apache.spark.shuffle.{GenShuffleReaderParameters,
GenShuffleWriterParameters, GlutenShuffleReaderWrapper,
GlutenShuffleWriterWrapper, VeloxShuffleUtils}
+import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.catalog.BucketSpec
import org.apache.spark.sql.catalyst.catalog.CatalogTypes.TablePartitionSpec
import org.apache.spark.sql.catalyst.expressions._
@@ -1416,6 +1417,41 @@ class VeloxSparkPlanExecApi extends SparkPlanExecApi
with Logging {
override def genColumnarRangeExec(rangeExec: RangeExec):
ColumnarRangeBaseExec =
ColumnarRangeExec(rangeExec.range)
+ override def isSupportRDDScanExec(plan: RDDScanExec): Boolean = {
+ if (!VeloxConfig.get.enableRddScan) {
+ logDebug(
+ "RDDScan offload skipped: " +
+ s"${VeloxConfig.COLUMNAR_VELOX_RDD_SCAN_ENABLED.key}=false")
+ return false
+ }
+ // Exclude any scan planned within a Structured Streaming query
(micro-batch or its
+ // foreachBatch callback). The per-batch source RDD is a materialized
snapshot that
+ // otherwise slips past the plan-level logicalLink.isStreaming fallback,
yet offloading
+ // it into a streaming/state-store pipeline can deadlock the micro-batch.
+ if (isWithinStreamingQuery) {
+ logDebug("RDDScan offload skipped: within a streaming query
(micro-batch/foreachBatch)")
+ return false
+ }
+ true
+ }
+
+ /**
+ * Whether the current thread is planning/executing a Structured Streaming
query -- including the
+ * `DataFrameWriter.foreachBatch` user callback, which Spark runs on the
StreamExecution driver
+ * thread. That thread sets the `sql.streaming.queryId` local property
+ * (`StreamExecution.QUERY_ID_KEY`) for the whole lifetime of the query. We
match on the literal
+ * key rather than referencing the class so this stays agnostic to the
per-Spark-version package
+ * of `StreamExecution` across shims.
+ */
+ private def isWithinStreamingQuery: Boolean =
+ SparkSession.getActiveSession
+ .map(_.sparkContext)
+ .flatMap(sc => Option(sc.getLocalProperty("sql.streaming.queryId")))
+ .isDefined
+
+ override def getRDDScanTransform(plan: RDDScanExec): RDDScanTransformer =
+ VeloxRDDScanTransformer.replace(plan)
+
override def genColumnarTailExec(limit: Int, child: SparkPlan):
ColumnarCollectTailBaseExec =
ColumnarCollectTailExec(limit, child)
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
index d735184916..7fb3f69dd3 100644
--- a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
+++ b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
@@ -89,6 +89,8 @@ class VeloxConfig(conf: SQLConf) extends GlutenConfig(conf) {
def veloxPreferredBatchBytes: Long =
getConf(COLUMNAR_VELOX_PREFERRED_BATCH_BYTES)
+ def enableRddScan: Boolean = getConf(COLUMNAR_VELOX_RDD_SCAN_ENABLED)
+
def cudfEnableTableScan: Boolean = getConf(CUDF_ENABLE_TABLE_SCAN)
def cudfEnableValidation: Boolean = getConf(CUDF_ENABLE_VALIDATION)
@@ -940,6 +942,16 @@ object VeloxConfig extends ConfigRegistry {
.bytesConf(ByteUnit.BYTE)
.createWithDefaultString("10MB")
+ val COLUMNAR_VELOX_RDD_SCAN_ENABLED =
+ buildConf("spark.gluten.sql.columnar.backend.velox.rddScan.enabled")
+ .doc(
+ "When true, offload RDDScanExec to Velox by converting the
RDD[InternalRow] into" +
+ " columnar batches through the native row-to-columnar path. Schemas
that are not" +
+ " supported by the Arrow export path (e.g. map or interval types)
fall back to" +
+ " vanilla Spark.")
+ .booleanConf
+ .createWithDefault(true)
+
val VELOX_MAX_COMPILED_REGEXES =
buildConf("spark.gluten.sql.columnar.backend.velox.maxCompiledRegexes")
.doc(
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/execution/ColumnarPartialProjectExec.scala
b/backends-velox/src/main/scala/org/apache/gluten/execution/ColumnarPartialProjectExec.scala
index d657c3e303..f2884af5ce 100644
---
a/backends-velox/src/main/scala/org/apache/gluten/execution/ColumnarPartialProjectExec.scala
+++
b/backends-velox/src/main/scala/org/apache/gluten/execution/ColumnarPartialProjectExec.scala
@@ -133,6 +133,10 @@ case class ColumnarPartialProjectExec(projectList:
Seq[Expression], child: Spark
return ValidationResult.failed(
"Attribute in the partial projected expressions contains unsupported
type")
}
+ if (projectAttributes.isEmpty) {
+ return ValidationResult.failed(
+ "The partial projected expressions do not reference any child column")
+ }
if (projectAttributes.size == child.output.size) {
return ValidationResult.failed(
"The partial projected expressions need all the columns in child
output")
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/execution/VeloxRDDScanTransformer.scala
b/backends-velox/src/main/scala/org/apache/gluten/execution/VeloxRDDScanTransformer.scala
new file mode 100644
index 0000000000..d875122f56
--- /dev/null
+++
b/backends-velox/src/main/scala/org/apache/gluten/execution/VeloxRDDScanTransformer.scala
@@ -0,0 +1,225 @@
+/*
+ * 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.backendsapi.velox.VeloxValidatorApi
+import org.apache.gluten.config.{GlutenConfig, VeloxConfig}
+
+import org.apache.spark.internal.Logging
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference,
SortOrder, UnsafeProjection}
+import org.apache.spark.sql.catalyst.plans.physical.Partitioning
+import org.apache.spark.sql.execution.{RDDScanTransformer, SparkPlan}
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
+import org.apache.spark.sql.types._
+import org.apache.spark.sql.vectorized.ColumnarBatch
+
+/**
+ * Velox-backend implementation of RDDScanTransformer.
+ *
+ * Converts an RDD[InternalRow] into columnar batches using Velox's native
row-to-columnar
+ * conversion (same JNI path as RowToVeloxColumnarExec).
+ */
+case class VeloxRDDScanTransformer(
+ outputAttributes: Seq[Attribute],
+ rdd: RDD[InternalRow],
+ name: String,
+ // Row-to-columnar conversion preserves data distribution, so we carry
through
+ // the original partitioning. This differs from CH which uses
UnknownPartitioning(0)
+ // but is consistent with RowToVeloxColumnarExec's behavior.
+ override val outputPartitioning: Partitioning,
+ override val outputOrdering: Seq[SortOrder]
+) extends RDDScanTransformer(outputAttributes, outputPartitioning,
outputOrdering)
+ with Logging {
+
+ override def nodeName: String = name
+
+ @transient override lazy val metrics: Map[String, SQLMetric] = Map(
+ "numInputRows" -> SQLMetrics.createMetric(sparkContext, "number of input
rows"),
+ "numOutputBatches" -> SQLMetrics.createMetric(sparkContext, "number of
output batches"),
+ "convertTime" -> SQLMetrics.createTimingMetric(sparkContext, "time to
convert")
+ )
+
+ override protected def doValidateInternal(): ValidationResult = {
+ if (schema.isEmpty) {
+ return ValidationResult.failed("RDDScan with an empty schema is not
supported")
+ }
+ for (field <- schema.fields) {
+ val reason = VeloxValidatorApi.validateSchema(field.dataType)
+ if (reason.isDefined) {
+ return ValidationResult.failed(reason.get)
+ }
+ val arrowReason = validateArrowCompatibility(field.dataType)
+ if (arrowReason.isDefined) {
+ return ValidationResult.failed(arrowReason.get)
+ }
+ }
+ ValidationResult.succeeded
+ }
+
+ override def doExecuteColumnar(): RDD[ColumnarBatch] = {
+ val numInputRows = longMetric("numInputRows")
+ val numOutputBatches = longMetric("numOutputBatches")
+ val convertTime = longMetric("convertTime")
+ val localSchema = this.schema
+ val batchSize = GlutenConfig.get.maxBatchSize
+ val batchBytes = VeloxConfig.get.veloxPreferredBatchBytes
+ rdd.mapPartitions {
+ iter =>
+ if (iter.hasNext) {
+ val first = iter.next()
+ // A partition is homogeneous by construction: a checkpoint/cache of
a Gluten
+ // columnar plan yields all BatchCarrierRows, while any other RDD
yields all
+ // InternalRows. We therefore select the path once, based on the
first row, and
+ // apply it to the whole partition.
+ first match {
+ case _: BatchCarrierRow =>
+ // RDD already contains columnar batches wrapped as carrier rows
+ // (e.g., from df.checkpoint() on a Gluten plan). Unwrap
directly.
+ // No row conversion happens here, so convertTime is
intentionally left at 0;
+ // numInputRows is credited with the already-batched row count
for observability.
+ (Iterator.single(first) ++ iter).flatMap {
+ row =>
+ BatchCarrierRow.unwrap(row).map {
+ batch =>
+ numOutputBatches += 1
+ numInputRows += batch.numRows()
+ batch
+ }
+ }
+ case _ =>
+ // Standard InternalRow path - convert via native
row-to-columnar.
+ val rowIter = Iterator.single(first) ++ iter
+ val processedIter = if (schemaHasNonNullableField(localSchema)) {
+ // Pre-convert rows to UnsafeRow respecting schema nullability.
+ // This matches Spark's codegen behavior: for non-nullable
fields, getLong/getInt
+ // is called directly without isNullAt check, so null values
in non-nullable
+ // columns produce the type's default (0 for Long) rather than
setting null bits.
+ // Without this, UnsafeProjection.create(schema) always uses
nullable=true,
+ // which would incorrectly propagate nulls for non-nullable
fields (SPARK-35912).
+ // The guard recurses into nested struct/array/map types so a
non-nullable field
+ // nested under a nullable top-level column still routes
through the projection
+ // (which itself handles nested nullability), rather than
silently skipping the fix.
+ createNullabilityAwareIterator(rowIter, localSchema)
+ } else {
+ rowIter
+ }
+ RowToVeloxColumnarExec.toColumnarBatchIterator(
+ processedIter,
+ localSchema,
+ numInputRows,
+ numOutputBatches,
+ convertTime,
+ batchSize,
+ batchBytes)
+ }
+ } else {
+ Iterator.empty
+ }
+ }
+ }
+
+ /**
+ * Returns true if `dataType` declares any non-nullable field at any nesting
level. The top-level
+ * routing guard uses this so that a non-nullable field nested inside an
otherwise-nullable
+ * struct/array/map column still triggers the nullability-aware projection,
matching Spark's
+ * codegen null->default behavior for non-nullable fields (SPARK-35912).
+ *
+ * Coercion is exact for (nested) struct fields, which UnsafeProjection
writes via typed getters
+ * that turn a null into the primitive default. It is best-effort for
elements nested inside a
+ * `containsNull = false` array or a `valueContainsNull = false` map: the
projection copies the
+ * array/map payload wholesale and does not rewrite individual element
nulls. Such payloads (a
+ * null element in a declared non-null collection produced by a raw RDD) are
pathological and not
+ * expected in practice; the guard still routes them through the projection
for consistency.
+ */
+ private def schemaHasNonNullableField(dataType: DataType): Boolean =
dataType match {
+ case s: StructType =>
+ s.fields.exists(f => !f.nullable ||
schemaHasNonNullableField(f.dataType))
+ case a: ArrayType =>
+ !a.containsNull || schemaHasNonNullableField(a.elementType)
+ case m: MapType =>
+ // Map keys are non-null by Spark semantics (not a data-null risk), so
they do not by
+ // themselves trigger the guard; only declared non-null values or nested
non-nullable
+ // fields in the key/value types do.
+ !m.valueContainsNull || schemaHasNonNullableField(m.keyType) ||
+ schemaHasNonNullableField(m.valueType)
+ case _ => false
+ }
+
+ /**
+ * Creates an iterator that converts InternalRows to UnsafeRows while
respecting schema
+ * nullability. For non-nullable fields, values are read via typed getters
(getLong, getInt, etc.)
+ * which return default values (0) for null inputs, matching Spark's
WholeStageCodegen behavior.
+ */
+ private def createNullabilityAwareIterator(
+ iter: Iterator[InternalRow],
+ schema: StructType): Iterator[InternalRow] = {
+ // Create BoundReferences that respect the schema's declared nullability.
+ // When nullable=false, the generated code calls getLong/getInt directly
without
+ // checking isNullAt, so null.asInstanceOf[Long] unboxes to 0.
+ val boundRefs = schema.fields.zipWithIndex.map {
+ case (field, i) => BoundReference(i, field.dataType, field.nullable)
+ }.toSeq
+ val projection = UnsafeProjection.create(boundRefs)
+ iter.map {
+ row =>
+ // The projection returns a mutable UnsafeRow that is reused across
calls. This is safe
+ // for two reasons: (1) toColumnarBatchIterator copies each row's
bytes into an ArrowBuf
+ // via Platform.copyMemory before advancing the iterator, and (2) its
convertToUnsafeRow
+ // passes an already-UnsafeRow straight through without re-projecting,
so this
+ // nullability-aware projection replaces (not adds to) the converter's
internal one.
+ projection.apply(row)
+ }
+ }
+
+ /**
+ * Additional validation for Arrow export compatibility. The RDDScan path
transfers data via Arrow
+ * ABI, which has stricter constraints than Velox's type system:
+ * - Map types can trigger "Map data key type should be a non-nullable" in
Arrow export
+ * - Interval types are not supported by ArrowWritableColumnVector
+ */
+ private def validateArrowCompatibility(dataType: DataType): Option[String] =
{
+ dataType match {
+ case _: MapType =>
+ Some(s"Map type is not supported in RDDScan Arrow export path:
$dataType")
+ case _: YearMonthIntervalType | _: DayTimeIntervalType |
CalendarIntervalType =>
+ Some(s"Interval type is not supported in Arrow export: $dataType")
+ case struct: StructType =>
+ struct.fields.flatMap(f =>
validateArrowCompatibility(f.dataType)).headOption
+ case array: ArrayType =>
+ validateArrowCompatibility(array.elementType)
+ case _ => None
+ }
+ }
+
+ override protected def withNewChildrenInternal(newChildren:
IndexedSeq[SparkPlan]): SparkPlan = {
+ assert(newChildren.isEmpty, "VeloxRDDScanTransformer is a leaf node")
+ copy(outputAttributes, rdd, name, outputPartitioning, outputOrdering)
+ }
+}
+
+object VeloxRDDScanTransformer {
+
+ def replace(plan: org.apache.spark.sql.execution.RDDScanExec):
RDDScanTransformer =
+ VeloxRDDScanTransformer(
+ plan.output,
+ plan.inputRDD,
+ plan.nodeName,
+ plan.outputPartitioning,
+ plan.outputOrdering)
+}
diff --git
a/backends-velox/src/test/scala/org/apache/spark/sql/execution/VeloxRDDScanSuite.scala
b/backends-velox/src/test/scala/org/apache/spark/sql/execution/VeloxRDDScanSuite.scala
new file mode 100644
index 0000000000..df55258f0e
--- /dev/null
+++
b/backends-velox/src/test/scala/org/apache/spark/sql/execution/VeloxRDDScanSuite.scala
@@ -0,0 +1,378 @@
+/*
+ * 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.spark.sql.execution
+
+import org.apache.gluten.backendsapi.BackendsApiManager
+import org.apache.gluten.config.VeloxConfig
+import org.apache.gluten.execution._
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference}
+import org.apache.spark.sql.classic.{ClassicDataset, ClassicTypes}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.types._
+import org.apache.spark.util.Utils
+
+class VeloxRDDScanSuite extends VeloxWholeStageTransformerSuite with
AdaptiveSparkPlanHelper {
+
+ override protected val resourcePath: String = "/tpch-data-parquet"
+ override protected val fileFormat: String = "parquet"
+
+ override protected def sparkConf: SparkConf = {
+ super.sparkConf
+ .set("spark.sql.ansi.enabled", "false")
+ .set("spark.gluten.sql.columnar.backend.velox.rddScan.enabled", "true")
+ }
+
+ override def beforeAll(): Unit = {
+ super.beforeAll()
+ createTPCHNotNullTables()
+ }
+
+ /** Creates a DataFrame backed by LogicalRDD/RDDScanExec from an existing
DataFrame. */
+ private def asRDDScanDF(data: DataFrame): DataFrame = {
+ val node = LogicalRDD(data.queryExecution.analyzed.output,
data.queryExecution.toRdd)(
+ data.sparkSession.asInstanceOf[ClassicTypes.ClassicSparkSession])
+ ClassicDataset.ofRows(spark, node).toDF()
+ }
+
+ /**
+ * Builds a DataFrame backed by LogicalRDD/RDDScanExec directly from raw
InternalRows and an
+ * explicit output schema. Unlike SparkSession.createDataFrame(RDD[Row],
schema), this bypasses
+ * the RowEncoder (which inserts assertnotnull for non-nullable fields and
would reject nulls), so
+ * we can inject nulls into non-nullable columns - exactly the case
createNullabilityAwareIterator
+ * handles.
+ */
+ private def rddScanDF(output: Seq[Attribute], rows: Seq[InternalRow]):
DataFrame = {
+ val rdd = spark.sparkContext.parallelize(rows)
+ val node = LogicalRDD(output,
rdd)(spark.asInstanceOf[ClassicTypes.ClassicSparkSession])
+ ClassicDataset.ofRows(spark, node).toDF()
+ }
+
+ test("basic RDDScanExec is replaced by VeloxRDDScanTransformer") {
+ val data = spark.sql("SELECT l_orderkey, l_partkey FROM lineitem LIMIT 10")
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan with string and numeric types") {
+ val data = spark.sql("""SELECT l_returnflag, l_linestatus, l_quantity,
l_extendedprice
+ |FROM lineitem LIMIT 20""".stripMargin)
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan with aggregation downstream") {
+ val query =
+ """SELECT l_returnflag, sum(l_quantity) AS sum_qty
+ |FROM lineitem
+ |WHERE l_shipdate <= date'1998-09-02'
+ |GROUP BY l_returnflag""".stripMargin
+ val data = spark.sql(query)
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan with empty RDD") {
+ val data = spark.sql("SELECT l_orderkey FROM lineitem WHERE 1 = 0")
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ assert(df.count() == 0)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan preserves data correctness with multiple re-reads") {
+ val data = spark.sql("SELECT l_orderkey, l_partkey FROM lineitem LIMIT 50")
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ // Read twice to verify idempotency
+ checkAnswer(df, expectedAnswer)
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan with null values") {
+ val rdd = spark.sparkContext.parallelize(
+ Seq(
+ Row(1, "a", null),
+ Row(null, "b", 2.0),
+ Row(3, null, 3.0)
+ ))
+ val schema = StructType(
+ Seq(
+ StructField("id", IntegerType, nullable = true),
+ StructField("name", StringType, nullable = true),
+ StructField("value", DoubleType, nullable = true)
+ ))
+ val data = spark.createDataFrame(rdd, schema)
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan with all supported primitive types") {
+ val rdd = spark.sparkContext.parallelize(
+ Seq(
+ Row(
+ true,
+ 1.toByte,
+ 2.toShort,
+ 3,
+ 4L,
+ 5.0f,
+ 6.0,
+ "hello",
+ java.sql.Date.valueOf("2024-01-01"),
+ java.sql.Timestamp.valueOf("2024-01-01 12:00:00"),
+ Array[Byte](1, 2, 3),
+ BigDecimal("123.45").underlying()
+ )
+ ))
+ val schema = StructType(
+ Seq(
+ StructField("bool", BooleanType),
+ StructField("byte", ByteType),
+ StructField("short", ShortType),
+ StructField("int", IntegerType),
+ StructField("long", LongType),
+ StructField("float", FloatType),
+ StructField("double", DoubleType),
+ StructField("string", StringType),
+ StructField("date", DateType),
+ StructField("timestamp", TimestampType),
+ StructField("binary", BinaryType),
+ StructField("decimal", DecimalType(10, 2))
+ ))
+ val data = spark.createDataFrame(rdd, schema)
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan with array type") {
+ val rdd = spark.sparkContext.parallelize(
+ Seq(
+ Row(Seq(1, 2, 3)),
+ Row(Seq(4, 5))
+ ))
+ val schema = StructType(Seq(StructField("arr", ArrayType(IntegerType))))
+ val data = spark.createDataFrame(rdd, schema)
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan with map type falls back to row-based") {
+ val rdd = spark.sparkContext.parallelize(
+ Seq(
+ Row(Map("a" -> 1, "b" -> 2)),
+ Row(Map("c" -> 3))
+ ))
+ val schema = StructType(Seq(StructField("m", MapType(StringType,
IntegerType))))
+ val data = spark.createDataFrame(rdd, schema)
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ // MapType is not supported in Arrow export, so falls back to row-based
processing
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.isEmpty, "MapType schema should fall back from
VeloxRDDScanTransformer")
+ }
+
+ test("RDDScan with an empty schema falls back to row-based") {
+ val plan = RDDScanExec(
+ Seq.empty,
+ spark.sparkContext.parallelize(Seq(InternalRow.empty)),
+ "OneRowRelation")
+ val transformer = RDDScanTransformer.getRDDScanTransform(plan)
+
+ assert(!transformer.doValidate().ok(), "Empty-schema RDDScan should not be
offloaded")
+ }
+
+ test("RDDScan with struct type") {
+ val rdd = spark.sparkContext.parallelize(
+ Seq(
+ Row(Row("hello", 1)),
+ Row(Row("world", 2))
+ ))
+ val innerSchema =
+ StructType(Seq(StructField("name", StringType), StructField("value",
IntegerType)))
+ val schema = StructType(Seq(StructField("s", innerSchema)))
+ val data = spark.createDataFrame(rdd, schema)
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan falls back for unsupported types") {
+ val data = spark.sql("SELECT INTERVAL '1' DAY AS di")
+ val expectedAnswer = data.collect()
+ val result = asRDDScanDF(data)
+
+ // Should still produce correct results via fallback to vanilla Spark
+ checkAnswer(result, expectedAnswer)
+ val cnt = collect(result.queryExecution.executedPlan) {
+ case _: VeloxRDDScanTransformer => true
+ }
+ assert(cnt.isEmpty, "Expected fallback - VeloxRDDScanTransformer should
NOT be in plan")
+ }
+
+ test("RDDScan handles BatchCarrierRow from checkpoint") {
+ val tempDir = Utils.createTempDir()
+ try {
+ spark.sparkContext.setCheckpointDir(tempDir.getAbsolutePath)
+ val df = spark.range(100).selectExpr("id", "id * 2 as value")
+ val checkpointed = df.localCheckpoint()
+ val result = asRDDScanDF(checkpointed)
+
+ checkAnswer(result, df.collect())
+ val cnt = collect(result.queryExecution.executedPlan) {
+ case _: VeloxRDDScanTransformer => true
+ }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ } finally {
+ Utils.deleteRecursively(tempDir)
+ }
+ }
+
+ test("falls back for schemas with interval types") {
+ val df = spark.sql("SELECT INTERVAL '1' YEAR as y")
+ val rddDf = asRDDScanDF(df)
+ checkAnswer(rddDf, df.collect())
+ // Should NOT use VeloxRDDScanTransformer (falls back due to interval type)
+ val veloxScans = collect(rddDf.queryExecution.executedPlan) {
+ case _: VeloxRDDScanTransformer => true
+ }
+ assert(veloxScans.isEmpty, "Interval type schema should fall back from
VeloxRDDScanTransformer")
+ }
+
+ test("RDDScan falls back when native kill-switch is disabled") {
+ withSQLConf(VeloxConfig.COLUMNAR_VELOX_RDD_SCAN_ENABLED.key -> "false") {
+ val data = spark.sql("SELECT l_orderkey, l_partkey FROM lineitem LIMIT
10")
+ val expectedAnswer = data.collect()
+ val df = asRDDScanDF(data)
+
+ checkAnswer(df, expectedAnswer)
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.isEmpty, "Expected fallback when native rddScan kill-switch
is disabled")
+ }
+ }
+
+ test("RDDScan nullability: null in non-nullable field yields Spark default
(SPARK-35912)") {
+ // Output attributes declare numeric fields non-nullable, but the backing
InternalRows
+ // carry nulls. This drives the createNullabilityAwareIterator branch
(entered only when
+ // the schema has a non-nullable field). Per SPARK-35912, a null read from
a non-nullable
+ // field via a typed getter unboxes to the primitive default (0), matching
WholeStageCodegen.
+ val output = Seq(
+ AttributeReference("i", IntegerType, nullable = false)(),
+ AttributeReference("l", LongType, nullable = false)(),
+ AttributeReference("d", DoubleType, nullable = false)()
+ )
+ val rows = Seq(InternalRow(1, 10L, 1.5d), InternalRow(null, null, null))
+ val df = rddScanDF(output, rows)
+
+ // The null row must surface Spark's type defaults (0 / 0L / 0.0), not
propagated nulls.
+ checkAnswer(df, Seq(Row(1, 10L, 1.5d), Row(0, 0L, 0.0d)))
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan nullability: mixed nullable and non-nullable fields") {
+ // Only the non-nullable field coerces null to a default; the nullable
field keeps null.
+ val output = Seq(
+ AttributeReference("nn", LongType, nullable = false)(),
+ AttributeReference("nl", LongType, nullable = true)())
+ val rows = Seq(InternalRow(5L, 7L), InternalRow(null, null))
+ val df = rddScanDF(output, rows)
+
+ checkAnswer(df, Seq(Row(5L, 7L), Row(0L, null)))
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("RDDScan nullability: non-nullable field nested under a nullable struct
coerces null") {
+ // Top-level field `a` is nullable, but its nested field `x` is
non-nullable. Previously the
+ // routing guard only inspected top-level nullability, so this schema
skipped the
+ // nullability-aware projection and a null nested `x` could
propagate/diverge from Spark.
+ // The recursive guard now routes it through the projection, coercing the
nested null to the
+ // type default (0L) per SPARK-35912, while a fully-null top-level struct
stays null.
+ val inner = StructType(Seq(StructField("x", LongType, nullable = false)))
+ val output = Seq(AttributeReference("a", inner, nullable = true)())
+ val rows = Seq(InternalRow(InternalRow(3L)),
InternalRow(InternalRow(null)), InternalRow(null))
+ val df = rddScanDF(output, rows)
+
+ checkAnswer(df, Seq(Row(Row(3L)), Row(Row(0L)), Row(null)))
+ val cnt = collect(df.queryExecution.executedPlan) { case _:
VeloxRDDScanTransformer => true }
+ assert(cnt.nonEmpty, "Expected VeloxRDDScanTransformer in plan")
+ }
+
+ test("isSupportRDDScanExec skips offload inside a streaming query
(foreachBatch)") {
+ // A Structured Streaming micro-batch (and its foreachBatch callback) runs
on the
+ // StreamExecution driver thread, which sets the `sql.streaming.queryId`
local property.
+ // The per-batch source is a materialized snapshot, so it slips past the
plan-level
+ // streaming fallback; offloading it into a streaming/state-store pipeline
can deadlock.
+ val output = Seq(AttributeReference("id", IntegerType)())
+ val rdd = spark.sparkContext.parallelize(Seq.empty[InternalRow])
+ val plan = RDDScanExec(output, rdd, "ExistingRDD")
+ val api = BackendsApiManager.getSparkPlanExecApiInstance
+
+ // Sanity: outside a streaming query this plan is offloadable.
+ assert(api.isSupportRDDScanExec(plan), "Non-streaming RDDScan should be
offloadable")
+
+ val sc = spark.sparkContext
+ val prev = sc.getLocalProperty("sql.streaming.queryId")
+ try {
+ sc.setLocalProperty("sql.streaming.queryId", "test-streaming-query-id")
+ assert(
+ !api.isSupportRDDScanExec(plan),
+ "RDDScan inside a streaming query must not be offloaded")
+ } finally {
+ sc.setLocalProperty("sql.streaming.queryId", prev)
+ }
+ }
+}
diff --git a/docs/velox-configuration.md b/docs/velox-configuration.md
index 3eca94a7f9..2c14b37c45 100644
--- a/docs/velox-configuration.md
+++ b/docs/velox-configuration.md
@@ -69,6 +69,7 @@ nav_order: 16
| spark.gluten.sql.columnar.backend.velox.parquetUseColumnNames
| 🔄 Dynamic | true | Maps table field names to file field
names using names, not indices for Parquet files.
[...]
| spark.gluten.sql.columnar.backend.velox.prefetchRowGroups
| âš“ Static | 1 | Set the prefetch row groups for velox
file scan
[...]
| spark.gluten.sql.columnar.backend.velox.queryTraceEnabled
| 🔄 Dynamic | false | Enable query tracing flag.
[...]
+| spark.gluten.sql.columnar.backend.velox.rddScan.enabled
| 🔄 Dynamic | true | When true, offload RDDScanExec to
Velox by converting the RDD[InternalRow] into columnar batches through the
native row-to-columnar path. Schemas that are not supported by the Arrow export
path (e.g. map or interval types) fall back to vanilla Spark.
[...]
| spark.gluten.sql.columnar.backend.velox.reclaimMaxWaitMs
| 🔄 Dynamic | 3600000ms | The max time in ms to wait for memory
reclaim.
[...]
| spark.gluten.sql.columnar.backend.velox.resizeBatches.copyRanges.enabled
| 🔄 Dynamic | true | Enables a VeloxResizeBatchesExec fast
path that combines eligible batches using Velox vector copyRanges instead of
generic RowVector append. When possible, it collects the small input batches
for one VeloxResizeBatchesExec output, allocates the output RowVector once, and
bulk-copies child vector ranges. This is most useful for shuffle-read outputs
where plain hash shuff [...]
| spark.gluten.sql.columnar.backend.velox.resizeBatches.shuffleInput
| 🔄 Dynamic | true | If true, combine small columnar
batches together before sending to shuffle. The default minimum output batch
size is equal to 0.25 * spark.gluten.sql.columnar.maxBatchSize
[...]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]