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

liuneng1994 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 85d3ca109e [GLUTEN-12280][VL] Fix Spark 4 Arrow Python UDF stream 
writer (#12345)
85d3ca109e is described below

commit 85d3ca109ebe38168b2a2e0503490f554df3ed3e
Author: Reema <[email protected]>
AuthorDate: Thu Jul 9 10:02:05 2026 +0000

    [GLUTEN-12280][VL] Fix Spark 4 Arrow Python UDF stream writer (#12345)
    
    What changes are proposed in this pull request?
    Fixes #12280.
    
    Fix Spark 4 Arrow Python UDF execution with the Velox backend by keeping 
the Arrow stream writer alive across input batches instead of reopening the IPC 
stream per batch.
    
    Also adds a regression test for Arrow Python UDF over Parquet scan
    
    How was this patch tested?
    Added ArrowEvalPythonExecSuite coverage.
    
    Verified locally on Spark 4.0.2 / Scala 2.13 / linux aarch64. The repro 
uses ColumnarArrowPythonRunner, returns max(ship_len) = 7, and no longer fails 
with Invalid IPC stream
---
 .../api/python/ColumnarArrowEvalPythonExec.scala   | 77 +++++++++++++++++++++-
 .../python/ArrowEvalPythonExecSuite.scala          | 71 ++++++++++++++++++++
 2 files changed, 147 insertions(+), 1 deletion(-)

diff --git 
a/backends-velox/src/main/scala/org/apache/spark/api/python/ColumnarArrowEvalPythonExec.scala
 
b/backends-velox/src/main/scala/org/apache/spark/api/python/ColumnarArrowEvalPythonExec.scala
index 6bb2f50e1f..831259b012 100644
--- 
a/backends-velox/src/main/scala/org/apache/spark/api/python/ColumnarArrowEvalPythonExec.scala
+++ 
b/backends-velox/src/main/scala/org/apache/spark/api/python/ColumnarArrowEvalPythonExec.scala
@@ -46,6 +46,7 @@ import java.util.concurrent.atomic.AtomicBoolean
 import scala.annotation.nowarn
 import scala.collection.mutable
 import scala.collection.mutable.ArrayBuffer
+import scala.util.control.NonFatal
 
 class ColumnarArrowPythonRunner(
     funcs: Seq[(ChainedPythonFunctions, Long)],
@@ -59,6 +60,9 @@ class ColumnarArrowPythonRunner(
 
   override val simplifiedTraceback: Boolean = 
SQLConf.get.pysparkSimplifiedTraceback
 
+  // Keep this source compatible with older Spark profiles where 
PythonEvalType differs.
+  private val SQL_ARROW_BATCHED_UDF = 101
+
   override val bufferSize: Int = SQLConf.get.pandasUDFBufferSize
   require(
     bufferSize >= 4,
@@ -150,6 +154,9 @@ class ColumnarArrowPythonRunner(
           PythonRDD.writeUTF(k, dataOut)
           PythonRDD.writeUTF(v, dataOut)
         }
+        if (SparkVersionUtil.gteSpark41 && evalType == SQL_ARROW_BATCHED_UDF) {
+          PythonRDD.writeUTF(schema.json, dataOut)
+        }
         ColumnarArrowPythonRunner.this.writeUdf(dataOut, argMetas)
       }
 
@@ -162,7 +169,75 @@ class ColumnarArrowPythonRunner(
       // For Spark 4.0. It overrides the corresponding abstract method in 
Writer class.
       // We omitted the override keyword for compatibility consideration.
       def writeNextInputToStream(dataOut: DataOutputStream): Boolean = {
-        writeToStreamHelper(dataOut)
+        writeNextInputToStreamHelper(dataOut)
+      }
+
+      private var nextInputRoot: VectorSchemaRoot = _
+      private var nextInputLoader: VectorLoader = _
+      private var nextInputWriter: ArrowStreamWriter = _
+      private var nextInputWriterClosed = false
+
+      context.addTaskCompletionListener[Unit] {
+        _ =>
+          try {
+            closeNextInputWriter()
+          } catch {
+            case NonFatal(_) =>
+          }
+      }
+
+      private def ensureNextInputWriter(dataOut: DataOutputStream): Unit = {
+        if (nextInputWriter == null) {
+          val arrowSchema = SparkSchemaUtil.toArrowSchema(schema, timeZoneId)
+          val allocator = ArrowBufferAllocators.contextInstance()
+          nextInputRoot = VectorSchemaRoot.create(arrowSchema, allocator)
+          nextInputLoader = new VectorLoader(nextInputRoot)
+          nextInputWriter = new ArrowStreamWriter(nextInputRoot, null, dataOut)
+          nextInputWriter.start()
+        }
+      }
+
+      private def closeNextInputWriter(): Unit = {
+        if (!nextInputWriterClosed && nextInputRoot != null) {
+          try {
+            if (nextInputWriter != null) {
+              nextInputWriter.end()
+            }
+          } finally {
+            nextInputRoot.close()
+            nextInputWriterClosed = true
+          }
+        }
+      }
+
+      private def writeNextInputToStreamHelper(dataOut: DataOutputStream): 
Boolean = {
+        ensureNextInputWriter(dataOut)
+        if (!inputIterator.hasNext) {
+          closeNextInputWriter()
+          // See https://issues.apache.org/jira/browse/SPARK-44705:
+          // Starting from Spark 4.0, we should return false once the iterator 
is drained out,
+          // otherwise Spark won't stop calling this method repeatedly.
+          return false
+        }
+        val nextBatch = inputIterator.next()
+        val cols = (0 until nextBatch.numCols).toList.map(
+          i =>
+            nextBatch
+              .asInstanceOf[ColumnarBatch]
+              .column(i)
+              .asInstanceOf[ArrowWritableColumnVector]
+              .getValueVector)
+        val nextRecordBatch =
+          SparkVectorUtil.toArrowRecordBatch(nextBatch.numRows, cols)
+        try {
+          nextInputLoader.load(nextRecordBatch)
+          nextInputWriter.writeBatch()
+          true
+        } finally {
+          if (nextRecordBatch != null) {
+            nextRecordBatch.close()
+          }
+        }
       }
 
       def writeToStreamHelper(dataOut: DataOutputStream): Boolean = {
diff --git 
a/backends-velox/src/test/scala/org/apache/gluten/execution/python/ArrowEvalPythonExecSuite.scala
 
b/backends-velox/src/test/scala/org/apache/gluten/execution/python/ArrowEvalPythonExecSuite.scala
index 7747bd2193..c6a6ac186e 100644
--- 
a/backends-velox/src/test/scala/org/apache/gluten/execution/python/ArrowEvalPythonExecSuite.scala
+++ 
b/backends-velox/src/test/scala/org/apache/gluten/execution/python/ArrowEvalPythonExecSuite.scala
@@ -21,6 +21,8 @@ import org.apache.gluten.execution.WholeStageTransformerSuite
 import org.apache.spark.SparkConf
 import org.apache.spark.api.python.ColumnarArrowEvalPythonExec
 import org.apache.spark.sql.IntegratedUDFTestUtils
+import org.apache.spark.sql.execution.python.UserDefinedPythonFunction
+import org.apache.spark.sql.functions.max
 import org.apache.spark.sql.types.{DataType, LongType, StringType}
 import org.apache.spark.util.SparkVersionUtil
 
@@ -36,6 +38,11 @@ class ArrowEvalPythonExecSuite extends 
WholeStageTransformerSuite {
     newTestScalarPandasUDF(name = "pyarrowUDF", returnType = Some(StringType))
   private val pyarrowTestUDFLong =
     newTestScalarPandasUDF(name = "pyarrowUDF", returnType = Some(LongType))
+  // Spark exposes these values through a package-private PythonEvalType 
object.
+  private val SQL_BATCHED_UDF = 100
+  private val SQL_ARROW_BATCHED_UDF = 101
+  private lazy val arrowBatchedTestUDFString =
+    newTestArrowBatchedPythonUDF(name = "arrowBatchedUDF", returnType = 
Some(StringType))
 
   override def sparkConf: SparkConf = {
     super.sparkConf
@@ -109,6 +116,70 @@ class ArrowEvalPythonExecSuite extends 
WholeStageTransformerSuite {
     checkAnswer(df, expected)
   }
 
+  testWithMinSparkVersion("arrow batched python udf over parquet scan", "4.0") 
{
+    withTempPath {
+      f =>
+        Seq(("MAIL", 1), ("RAIL", 2), ("SHIP", 3)).toDF("shipmode", 
"id").write.parquet(
+          f.getCanonicalPath)
+        val base = spark.read.parquet(f.getCanonicalPath)
+        val arrowUdfCol = 
arrowBatchedTestUDFString(base("shipmode")).as("shipmode_arrow")
+        val df = 
base.select(arrowUdfCol).agg(max("shipmode_arrow").as("max_shipmode"))
+        val expected = Seq(Tuple1("SHIP")).toDF("max_shipmode")
+
+        checkAnswer(df, expected)
+        checkSparkPlan[ColumnarArrowEvalPythonExec](df)
+    }
+  }
+
+  private def newTestArrowBatchedPythonUDF(
+      name: String,
+      returnType: Option[DataType] = None): UserDefinedPythonFunction = {
+    val pythonUDF =
+      newTestPythonUDF(name, returnType, Some(SQL_ARROW_BATCHED_UDF))
+    val udf = pythonUDF.getClass
+      .getMethod("udf")
+      .invoke(pythonUDF)
+      .asInstanceOf[UserDefinedPythonFunction]
+    if (SparkVersionUtil.gteSpark41) {
+      udf
+    } else {
+      udf.copy(
+        udf.name,
+        udf.func,
+        udf.dataType,
+        SQL_ARROW_BATCHED_UDF,
+        udf.udfDeterministic)
+    }
+  }
+
+  private def newTestPythonUDF(
+      name: String,
+      returnType: Option[DataType] = None,
+      pythonEvalType: Option[Int] = None): TestPythonUDF = {
+    if (SparkVersionUtil.gteSpark41) {
+      classOf[TestPythonUDF]
+        .getConstructor(
+          classOf[String],
+          classOf[Option[DataType]],
+          Integer.TYPE,
+          java.lang.Boolean.TYPE)
+        .newInstance(
+          name,
+          returnType,
+          pythonEvalType.getOrElse(SQL_BATCHED_UDF).asInstanceOf[Integer],
+          java.lang.Boolean.TRUE)
+    } else if (SparkVersionUtil.gteSpark40) {
+      // After https://github.com/apache/spark/pull/42864 which landed in 
Spark 4.0, the return
+      // type of the UDF must be explicitly specified when creating the UDF 
instance with column
+      // expressions as parameter.
+      classOf[TestPythonUDF]
+        .getConstructor(classOf[String], classOf[Option[DataType]])
+        .newInstance(name, returnType)
+    } else {
+      TestPythonUDF(name)
+    }
+  }
+
   private def newTestScalarPandasUDF(
       name: String,
       returnType: Option[DataType] = None): TestScalarPandasUDF = {


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

Reply via email to