viirya commented on code in PR #58978:
URL: https://github.com/apache/spark/pull/58978#discussion_r4321764504


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/InProcessArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -0,0 +1,581 @@
+/*
+ * 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.python
+
+import java.io.File
+import java.nio.file.Files
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
+import java.util.concurrent.locks.ReentrantLock
+
+import scala.collection.mutable.ArrayBuffer
+import scala.jdk.CollectionConverters._
+
+import com.google.common.util.concurrent.Uninterruptibles
+import org.apache.arrow.c.{ArrowArray, ArrowSchema, BaseStruct}
+import org.apache.arrow.util.AutoCloseables
+import org.apache.arrow.vector.VectorSchemaRoot
+
+import org.apache.spark.{SparkEnv, SparkException, TaskContext}
+import org.apache.spark.api.python.ChainedPythonFunctions
+import org.apache.spark.memory.MemoryConsumer
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression, 
JoinedRow, PythonUDF, UnsafeProjection, UnsafeRow}
+import org.apache.spark.sql.execution.arrow.ArrowWriter
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.execution.python.EvalPythonExec.ArgumentMetadata
+import org.apache.spark.sql.types._
+import org.apache.spark.sql.util.ArrowUtils
+import org.apache.spark.sql.vectorized.{ArrowColumnVector, ColumnarBatch, 
ColumnVector}
+import org.apache.spark.util.Utils
+
+/**
+ * Evaluates scalar Python UDFs using Arrow CDI in the executor process. Only 
UDF arguments
+ * are converted to Arrow. Original rows are buffered in a spillable queue and 
joined with
+ * the results, unless all of them are UDF arguments that read back from Arrow 
unchanged.
+ * Each batch owns its Arrow buffers so Python can safely retain input arrays.
+ *
+ * The evaluator owns its queue, so that cleanup at task completion is 
coordinated with a
+ * consumer on another thread, such as a pipelined Python writer or a 
TRANSFORM feed thread.
+ */
+class InProcessArrowEvalPythonEvaluatorFactory(
+    childOutput: Seq[Attribute],
+    udfs: Seq[PythonUDF],
+    output: Seq[Attribute],
+    batchSize: Int,
+    maxBytes: Long,
+    timeZoneId: String,
+    largeVarTypes: Boolean,
+    hideTraceback: Boolean,
+    simplifiedTraceback: Boolean,
+    tracebackWithLocals: Boolean,
+    fullValidation: Boolean,
+    metrics: Map[String, SQLMetric])
+  extends EvalPythonEvaluatorFactory(childOutput, udfs, output) {
+
+  private[python] def runtimeSession: 
InProcessPythonRuntime.InterpreterSession =
+    InProcessPythonRuntime.currentSession
+
+  /** Unused: `evaluateJoined` always evaluates the UDFs. */
+  override protected def evaluate(
+      funcs: Seq[(ChainedPythonFunctions, Long)],
+      argMetas: Array[Array[ArgumentMetadata]],
+      rows: Iterator[InternalRow],
+      inputSchema: StructType,
+      context: TaskContext): Iterator[InternalRow] =
+    throw SparkException.internalError("In-process UDFs are evaluated with 
their input rows")
+
+  override protected def evaluateJoined(
+      funcs: Seq[(ChainedPythonFunctions, Long)],
+      argMetas: Array[Array[ArgumentMetadata]],
+      rows: Iterator[InternalRow],
+      inputs: Seq[Expression],
+      inputSchema: StructType,
+      context: TaskContext): Option[Iterator[InternalRow]] = {
+    import InProcessArrowEvalPythonEvaluatorFactory.{Buffered, ReadBack, 
readsBack}
+    val inputColumns = inputs.length == childOutput.length && 
inputs.zip(childOutput).forall {
+      case (a: Attribute, c) => a.exprId == c.exprId
+      case _ => false
+    }
+    // If all input columns are UDF arguments, they are written to Arrow 
regardless. Read them
+    // back from the exported input vectors instead of buffering every input 
row, if their
+    // values read back from Arrow exactly as written and as fast as an unsafe 
row copy.
+    val joinInput = if (inputColumns && inputSchema.forall(f => 
readsBack(f.dataType))) {
+      ReadBack
+    } else if (inputColumns) {
+      Buffered(None)
+    } else {
+      // Each projected row is written to Arrow before the next input row is 
pulled, so the
+      // arguments go into a reused buffer rather than being copied value by 
value.
+      val projection = UnsafeProjection.create(inputs, childOutput)
+      projection.initialize(context.partitionId())
+      Buffered(Some(projection))
+    }
+    Some(evaluateBatches(funcs, argMetas, rows, inputSchema, context, 
joinInput))
+  }
+
+  private[python] def evaluateBatches(
+      funcs: Seq[(ChainedPythonFunctions, Long)],
+      argMetas: Array[Array[ArgumentMetadata]],
+      rows: Iterator[InternalRow],
+      inputSchema: StructType,
+      context: TaskContext,
+      joinInput: InProcessArrowEvalPythonEvaluatorFactory.JoinInput): 
Iterator[InternalRow] = {
+    import InProcessArrowEvalPythonEvaluatorFactory.{Buffered, ReadBack}
+    ArrowUtils.failDuplicatedFieldNames(inputSchema)
+    val functions = funcs.map { case (chain, _) =>
+      if (chain.funcs.size != 1) {
+        throw SparkException.internalError(
+          "In-process UDF chains must use separate evaluation nodes")
+      }
+      chain.funcs.head
+    }
+    val inputOrdinals = argMetas.map(_.map(_.offset))
+    def checkCancellation(): Unit = context.killTaskIfInterrupted()
+
+    val expectedFields = udfs.map { udf =>
+      ArrowUtils.toArrowField("result", udf.dataType, true, timeZoneId, 
largeVarTypes)
+    }
+    val processingTime = new 
InProcessArrowEvalPythonEvaluatorFactory.NanosecondTimer(
+      metrics("pythonProcessingTime"))
+    val initTime = new 
InProcessArrowEvalPythonEvaluatorFactory.NanosecondTimer(
+      metrics("pythonInitTime"))
+    val arrowSchema = ArrowUtils.toArrowSchema(inputSchema, timeZoneId, 
largeVarTypes)
+    // Capture before consuming input: an old task must never join a later 
context's session.
+    val runtime = runtimeSession
+    // Rows are copied out of the queue and Arrow vectors before they are 
returned, so they
+    // remain valid after task completion releases those, on whichever thread 
consumes them.
+    val resultProj = UnsafeProjection.create(output, output)
+    // Spill files go into a directory of the queue's own, created with the 
first disk queue, so
+    // that task completion can delete them when it cannot close the queue.
+    @volatile var spillDir: File = null
+    // Guarded by the queue's monitor.
+    var queueAbandoned = false
+    val (queue, projection) = joinInput match {
+      case Buffered(projection) =>
+        val localDir = new File(Utils.getLocalDir(SparkEnv.get.conf))
+        val serializerManager = SparkEnv.get.serializerManager
+        // Only the consumer holding the iterator's lock adds and removes rows.
+        val queue = new HybridRowQueue(context.taskMemoryManager(), localDir,
+            childOutput.length, serializerManager, lockFree = true) {
+          override protected def createDiskQueue(): RowQueue = synchronized {
+            if (spillDir == null) {
+              spillDir = Files.createTempDirectory(localDir.toPath, 
"inprocess-udf-").toFile
+            }
+            DiskRowQueue(Files.createTempFile(spillDir.toPath, "buffer", 
"").toFile,
+              childOutput.length, serializerManager)
+          }
+
+          // Once task completion leaves the queue to the executor, it must 
not spill for other
+          // consumers into a directory that nothing deletes.
+          override def spill(size: Long, trigger: MemoryConsumer): Long = 
synchronized {
+            if (queueAbandoned) 0L else super.spill(size, trigger)
+          }
+        }
+        (queue, projection.orNull)
+      case ReadBack => (null, null)
+    }
+    val joined = new JoinedRow
+    val handles = functions.map(_ => UUID.randomUUID().toString)
+    var registered = false
+    var writer: ArrowWriter = null
+    val results = ArrayBuffer.empty[ArrowColumnVector]
+
+    def closeBatch(): Unit = {
+      val resources = ArrayBuffer.empty[AutoCloseable]
+      resources ++= results
+      results.clear()
+      if (writer != null) {
+        resources += writer.root
+        writer = null
+      }
+      AutoCloseables.close(resources.asJava)
+    }
+
+    val resources = new 
InProcessArrowEvalPythonEvaluatorFactory.IteratorResources(
+      // Closing the queue deletes the spill files it tracks; deleteQuietly 
also removes any
+      // other, without starting a process or throwing, also on an interrupted 
thread.
+      releaseTaskMemory = () => if (queue != null) {
+        Utils.tryWithSafeFinally(queue.close())(Utils.deleteQuietly(spillDir))
+      },
+      abandonTaskMemory = () => if (queue != null) {
+        queue.synchronized {
+          queueAbandoned = true
+          Utils.deleteQuietly(spillDir)
+        }
+      },
+      recordTime = nanos => metrics("pythonTotalTime") += nanos / 1000000,
+      releaseOthers = () => {
+        Utils.tryWithSafeFinally {
+          closeBatch()
+        } {
+          if (registered) runtime.release(handles)
+        }
+      })
+
+    context.addTaskCompletionListener[Unit](_ => resources.close())
+
+    new Iterator[InternalRow] {
+      private var batchIter: Iterator[InternalRow] = Iterator.empty
+
+      private def endOfInput: Nothing =
+        throw new NoSuchElementException("End of in-process UDF input")
+
+      // Releases the resources on failure without replacing its exception.
+      private def fail(t: Throwable): Nothing =
+        Utils.tryWithSafeFinally { throw t } { resources.close() }
+
+      // Called with the lock held.
+      private def hasNextLocked: Boolean = {
+        resources.start()
+        checkCancellation()
+        val available = batchIter.hasNext || {
+          var open = false
+          val more = try {
+            resources.startReadingInput() && rows.hasNext
+          } finally {
+            open = resources.endReadingInput()
+          }
+          more && open
+        }
+        if (!available) resources.close()
+        available
+      }
+
+      // Each call takes the lock without allocating a closure per row. Within 
a batch, only
+      // the consumer advances `batchIter`, whose `hasNext` compares row 
indexes.
+      override def hasNext: Boolean = {
+        if (batchIter.hasNext && !resources.isClosed) return true
+        if (!resources.enter()) return false
+        val available = try {
+          try hasNextLocked catch { case t: Throwable => fail(t) }
+        } finally {
+          resources.exit()
+        }
+        // Task completion may have closed the iterator while the input was 
being read.
+        available && !resources.isClosed
+      }
+
+      override def next(): InternalRow = {
+        if (!resources.enter()) endOfInput
+        try {
+          try {
+            if (!hasNextLocked) endOfInput
+            if (!batchIter.hasNext) nextBatch()
+            val result = batchIter.next()
+            resultProj(if (queue != null) joined(queue.remove(), result) else 
result)
+          } catch {
+            case t: Throwable => fail(t)
+          }
+        } finally {
+          resources.exit()
+        }
+      }
+
+      // Runs Python without the lock unless task completion already happened, 
adds the time
+      // it took, and ends the input if task completion happened meanwhile.
+      private def python(timer: 
InProcessArrowEvalPythonEvaluatorFactory.NanosecondTimer)(
+          body: => Long): Unit = {
+        if (resources.isClosed) endOfInput
+        timer.add(resources.withoutLock(body))
+        if (resources.isClosed) endOfInput
+      }
+
+      /**
+       * Writes the next input row to the batch, returning false at the end of 
input or once
+       * task completion happened. If it happens while the row is read, the 
row is dropped.
+       */
+      private def pullRow(): Boolean = {
+        var open = false
+        val row = try {
+          // Checked again after `hasNext`, which may wait for input, not to 
read another row.
+          if (resources.startReadingInput() && rows.hasNext && 
!resources.isClosed) {
+            rows.next()
+          } else {
+            null
+          }
+        } finally {
+          open = resources.endReadingInput()
+        }
+        if (!open) endOfInput
+        if (row == null) return false
+        if (queue != null) queue.add(row.asInstanceOf[UnsafeRow])
+        writer.write(if (projection != null) projection(row) else row)
+        true
+      }
+
+      // Called with the lock held.
+      private def nextBatch(): Unit = {
+        closeBatch()
+        val root = VectorSchemaRoot.create(arrowSchema, 
ArrowUtils.rootAllocator)

Review Comment:
   Done in 8aead4b: before `ArrowWriter.create`, each new vector gets 
`setInitialCapacity` from the previous batch's value count, with its density 
for variable-width vectors. On Apple Silicon, a 2M-row long-string workload 
went from 0.376-0.388 s to 0.339-0.343 s per query with only this change 
toggled, and a 5M-row bigint workload is unchanged (medians 0.233 vs 0.233 s 
over six ABBA runs against 01b61ff).



##########
python/pyspark/inprocess/udf.py:
##########
@@ -0,0 +1,310 @@
+#
+# 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.
+#
+
+"""
+Python API for in-process UDF registration.
+
+Usage::
+
+    import pyarrow.compute as pc
+    from pyspark.inprocess import inprocess_udf
+    from pyspark.sql.types import LongType
+
+    @inprocess_udf(returnType=LongType())
+    def double(x):
+        # x is a pa.Array; return a pa.Array
+        return pc.multiply(x, 2)
+
+    df.select(double(df.value)).show()
+"""
+
+import io
+import sys
+from functools import update_wrapper
+from inspect import signature
+from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
+
+from pyspark import Accumulator, Broadcast, cloudpickle
+from pyspark.errors import PySparkNotImplementedError, PySparkTypeError, 
PySparkValueError
+from pyspark.sql.column import Column
+from pyspark.sql.types import DataType, _parse_datatype_string
+from pyspark.util import PythonEvalType
+
+if TYPE_CHECKING:
+    from py4j.java_gateway import JavaObject
+
+
+class _InProcessPickler(cloudpickle.CloudPickler):
+    def reducer_override(self, obj: Any) -> Any:
+        if isinstance(obj, (Broadcast, Accumulator)):
+            raise PySparkNotImplementedError(
+                errorClass="NOT_IMPLEMENTED",
+                messageParameters={
+                    "feature": "Spark broadcasts or accumulators in in-process 
UDFs"
+                },
+            )
+        return super().reducer_override(obj)
+
+
+def _serialize_udf(func: Callable) -> bytes:
+    buffer = io.BytesIO()
+    _InProcessPickler(buffer).dump(func)
+    return buffer.getvalue()
+
+
+def _check_return_type_argument(returnType: Any) -> Union[DataType, str]:
+    if not isinstance(returnType, (DataType, str)):
+        raise PySparkTypeError(
+            errorClass="NOT_EXPECTED_TYPE",
+            messageParameters={
+                "expected_type": "DataType or str",
+                "arg_name": "returnType",
+                "arg_type": type(returnType).__name__,
+            },
+        )
+    return returnType
+
+
+class InProcessUDFWrapper:
+    """
+    Wraps a Python function as an in-process UDF.
+
+    Returned by ``@inprocess_udf``. Calling an instance with Spark ``Column``
+    arguments creates a ``Column`` expression backed by ``PythonUDF``
+    on the JVM side.
+    """
+
+    def __init__(
+        self, func: Callable, returnType: Union[DataType, str], deterministic: 
bool = True
+    ) -> None:
+        _check_return_type_argument(returnType)
+        self._return_type = returnType
+        self._parsed_return_type: Optional[DataType] = None
+        self.evalType = PythonEvalType.SQL_SCALAR_ARROW_INPROCESS_UDF
+        self._deterministic: bool = deterministic
+        self._name: str = getattr(func, "__name__", "inprocess_udf")
+
+        from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+
+        require_minimum_pyarrow_version()
+        if not signature(func).parameters:
+            raise PySparkValueError(
+                errorClass="INVALID_PANDAS_UDF",
+                messageParameters={"detail": "0-arg inprocess_udfs are not 
supported."},
+            )
+        self._func = func
+        self._serialized: Optional[bytes] = None

Review Comment:
   Done in 8aead4b: `_serialize()` no longer caches the command, so after the 
first call only the JVM-side function holds it, and `__getstate__` drops only 
`_jfunc`. The tests that checked that nothing is serialized before validation 
now patch `_serialize_udf`, and the stability of the command after the first 
call is checked end to end in "test_command_is_stable_after_the_first_call".



##########
python/pyspark/inprocess/udf.py:
##########
@@ -0,0 +1,310 @@
+#
+# 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.
+#
+
+"""
+Python API for in-process UDF registration.
+
+Usage::
+
+    import pyarrow.compute as pc
+    from pyspark.inprocess import inprocess_udf
+    from pyspark.sql.types import LongType
+
+    @inprocess_udf(returnType=LongType())
+    def double(x):
+        # x is a pa.Array; return a pa.Array
+        return pc.multiply(x, 2)
+
+    df.select(double(df.value)).show()
+"""
+
+import io
+import sys
+from functools import update_wrapper
+from inspect import signature
+from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
+
+from pyspark import Accumulator, Broadcast, cloudpickle
+from pyspark.errors import PySparkNotImplementedError, PySparkTypeError, 
PySparkValueError
+from pyspark.sql.column import Column
+from pyspark.sql.types import DataType, _parse_datatype_string
+from pyspark.util import PythonEvalType
+
+if TYPE_CHECKING:
+    from py4j.java_gateway import JavaObject
+
+
+class _InProcessPickler(cloudpickle.CloudPickler):
+    def reducer_override(self, obj: Any) -> Any:
+        if isinstance(obj, (Broadcast, Accumulator)):
+            raise PySparkNotImplementedError(
+                errorClass="NOT_IMPLEMENTED",
+                messageParameters={
+                    "feature": "Spark broadcasts or accumulators in in-process 
UDFs"
+                },
+            )
+        return super().reducer_override(obj)
+
+
+def _serialize_udf(func: Callable) -> bytes:
+    buffer = io.BytesIO()
+    _InProcessPickler(buffer).dump(func)
+    return buffer.getvalue()
+
+
+def _check_return_type_argument(returnType: Any) -> Union[DataType, str]:
+    if not isinstance(returnType, (DataType, str)):
+        raise PySparkTypeError(
+            errorClass="NOT_EXPECTED_TYPE",
+            messageParameters={
+                "expected_type": "DataType or str",
+                "arg_name": "returnType",
+                "arg_type": type(returnType).__name__,
+            },
+        )
+    return returnType
+
+
+class InProcessUDFWrapper:
+    """
+    Wraps a Python function as an in-process UDF.
+
+    Returned by ``@inprocess_udf``. Calling an instance with Spark ``Column``
+    arguments creates a ``Column`` expression backed by ``PythonUDF``
+    on the JVM side.
+    """
+
+    def __init__(
+        self, func: Callable, returnType: Union[DataType, str], deterministic: 
bool = True
+    ) -> None:
+        _check_return_type_argument(returnType)
+        self._return_type = returnType
+        self._parsed_return_type: Optional[DataType] = None
+        self.evalType = PythonEvalType.SQL_SCALAR_ARROW_INPROCESS_UDF
+        self._deterministic: bool = deterministic
+        self._name: str = getattr(func, "__name__", "inprocess_udf")
+
+        from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+
+        require_minimum_pyarrow_version()
+        if not signature(func).parameters:
+            raise PySparkValueError(
+                errorClass="INVALID_PANDAS_UDF",
+                messageParameters={"detail": "0-arg inprocess_udfs are not 
supported."},
+            )
+        self._func = func
+        self._serialized: Optional[bytes] = None
+        # The JVM-side function, created once so that calls do not resend the 
command.
+        self._jfunc: Optional["JavaObject"] = None
+        update_wrapper(self, func, updated=())
+
+    @property
+    def func(self) -> Callable:
+        return self._func
+
+    @property
+    def returnType(self) -> DataType:
+        if self._parsed_return_type is None:
+            parsed = (
+                _parse_datatype_string(self._return_type)
+                if isinstance(self._return_type, str)
+                else self._return_type
+            )
+            from pyspark.sql.udf import UserDefinedFunction
+
+            UserDefinedFunction._check_return_type(parsed, 
PythonEvalType.SQL_SCALAR_ARROW_UDF)
+            from pyspark.sql.pandas.types import to_arrow_type
+
+            to_arrow_type(parsed, timezone="UTC", 
error_on_duplicated_field_names_in_struct=True)
+            self._parsed_return_type = parsed
+        return self._parsed_return_type
+
+    @property
+    def deterministic(self) -> bool:
+        return self._deterministic
+
+    def asNondeterministic(self) -> "InProcessUDFWrapper":
+        self._deterministic = False
+        return self
+
+    def __getstate__(self) -> Dict[str, Any]:
+        # The JVM-side function holds the py4j gateway, which cannot be 
pickled or copied, and
+        # the serialized command is labeled with the version of the process 
that sends it, so
+        # a copy, possibly in another Python, serializes the function and 
creates its own.
+        state = self.__dict__.copy()
+        state["_jfunc"] = None
+        state["_serialized"] = None
+        return state
+
+    def _serialize(self) -> bytes:
+        if self._serialized is None:
+            # Validate before caching the command, including driver-only UDT 
definitions.
+            self.returnType
+            self._serialized = _serialize_udf(self._func)
+        return self._serialized
+
+    def __call__(self, *cols: Union[Column, str], **kwargs: Union[Column, 
str]) -> Column:
+        """
+        Create a ``Column`` expression invoking this UDF with the given 
columns.
+
+        Args:
+            *cols: Spark ``Column`` objects (e.g. ``df.value``, ``col("x")``)
+
+        Returns:
+            pyspark.sql.Column
+        """
+        from pyspark.sql.classic.column import _to_java_column
+        from pyspark.sql.utils import get_active_spark_context, is_remote
+
+        if is_remote():
+            raise PySparkNotImplementedError(
+                errorClass="NOT_IMPLEMENTED",
+                messageParameters={"feature": "In-process Python UDFs in Spark 
Connect"},
+            )
+        sc = get_active_spark_context()
+
+        jvm = sc._jvm
+        assert jvm is not None
+
+        # Convert Python Column objects to JVM Column objects
+        if not cols and not kwargs:
+            raise PySparkValueError(
+                errorClass="INVALID_PANDAS_UDF",
+                messageParameters={"detail": "An inprocess_udf requires at 
least one argument."},
+            )
+        jcols = [_to_java_column(c) for c in cols]
+        jcols.extend(
+            jvm.PythonSQLUtils.namedArgumentExpression(name, 
_to_java_column(value))
+            for name, value in kwargs.items()
+        )
+
+        # Build a Java ArrayList (py4j vararg spread doesn't work with 
Arrays.asList)
+        jlist = jvm.java.util.ArrayList()
+        for jcol in jcols:
+            jlist.add(jcol)
+
+        if self._jfunc is None:
+            # Use the existing PythonUDF planning contracts with an in-process 
eval type.
+            builder = 
jvm.org.apache.spark.sql.execution.python.InProcessPythonUDFBuilder
+            self._jfunc = builder.create(
+                self._name,
+                self._serialize(),
+                self.returnType.json(),
+                "%d.%d" % sys.version_info[:2],
+            )
+        return Column(self._jfunc.apply(jlist, self._deterministic))
+
+
+def inprocess_udf(
+    f: Optional[Union[Callable, DataType, str]] = None,
+    returnType: Optional[Union[DataType, str]] = None,
+    *,
+    deterministic: bool = True,
+) -> Any:
+    """
+    Creates an in-process UDF, as a decorator or from a function, like 
``arrow_udf``.
+
+    .. versionadded:: 4.4.0
+
+    The function receives one ``pa.Array`` per input column and must
+    return a single ``pa.Array`` of the declared ``returnType``.
+
+    The result must have the same length as the input batch and its Arrow type
+    must match the declared Spark type, including nested fields. Timezone-aware
+    timestamps are relabeled to the session timezone without changing their 
instants;
+    string/binary offset widths are converted to match ``useLargeVarTypes``, 
and
+    ``large_list``, ``fixed_size_list``, ``string_view``, ``binary_view``,
+    ``fixed_size_binary`` and dictionary representations are cast to the 
declared type.
+    Other value types must match exactly. Nested nullability may be widened, 
but actual
+    nulls cannot be returned in non-nullable fields. Sliced results are copied 
when
+    required by Arrow Java.
+
+    Spark broadcasts, accumulators, ``SparkFiles``, ``--py-files``,
+    ``spark.submit.pyFiles``, ``SparkContext.addPyFile`` and
+    ``SparkSession.addArtifacts(..., pyfile=True)`` are unsupported.
+    Install dependencies on executors before starting Spark. The driver's 
Python
+    major.minor version must match the embedded interpreter.
+
+    Parameters
+    ----------
+    f : function, :class:`pyspark.sql.types.DataType` or str, optional
+        The function to wrap. When used as a decorator, the return type may be 
passed here
+        instead, as in ``@inprocess_udf(LongType())``.
+    returnType : :class:`pyspark.sql.types.DataType` or str
+        The return type of the UDF, as a DataType or a DDL-formatted type 
string.
+    deterministic : bool, optional
+        Keyword-only. Whether this UDF produces the same output for the same 
input. Set to
+        ``False`` for UDFs that use randomness or external state, so the 
optimizer does not
+        deduplicate or reorder calls to this UDF. Default: ``True``.
+
+    Returns
+    -------
+    ``InProcessUDFWrapper`` if ``f`` is a function, otherwise a decorator that 
wraps a
+    function as one.
+
+    Examples
+    --------
+    .. code-block:: python
+
+        import pyarrow.compute as pc
+
+        double = inprocess_udf(lambda x: pc.multiply(x, 2), LongType())
+
+        @inprocess_udf(returnType=LongType())
+        def double(x):
+            import pyarrow.compute as pc
+            return pc.multiply(x, 2)
+
+        @inprocess_udf(returnType=LongType(), deterministic=False)
+        def random_noise(x):
+            import pyarrow as pa, numpy as np
+            return pa.array(np.random.randint(0, 100, len(x)), type=pa.int64())
+    """
+
+    if not isinstance(deterministic, bool):
+        raise PySparkTypeError(
+            errorClass="NOT_EXPECTED_TYPE",
+            messageParameters={
+                "expected_type": "bool",
+                "arg_name": "deterministic",
+                "arg_type": type(deterministic).__name__,
+            },
+        )
+    if f is None or isinstance(f, (DataType, str)):
+        # @inprocess_udf(returnType) or @inprocess_udf(returnType=returnType)
+        if f is not None and returnType is not None:
+            raise PySparkTypeError(
+                errorClass="CANNOT_SET_TOGETHER",
+                messageParameters={"arg_list": "a return type in f and 
returnType"},
+            )
+        # Fails at once rather than when the decorator is applied.
+        return_type = _check_return_type_argument(f if f is not None else 
returnType)
+
+        def decorator(func: Callable) -> InProcessUDFWrapper:

Review Comment:
   Done in 8aead4b: `InProcessUDFWrapper.__init__` raises `NOT_EXPECTED_TYPE` 
for a non-callable function, so `inprocess_udf("long")(42)` fails like 
`inprocess_udf(42)`, and `Returns` now has a type line with the description 
indented below it.



##########
python/pyspark/inprocess/runtime.py:
##########
@@ -0,0 +1,552 @@
+#
+# 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.
+#
+
+
+"""Arrow CDI entry points called on the executor's dedicated JEP interpreter 
thread.
+
+Functions are registered once per task and released when that task finishes. 
Calls
+pass only a handle and CDI addresses, so large closures are not copied per 
batch.
+"""
+
+import re
+import sys
+from typing import Any, Callable, Iterable, NamedTuple, Optional, Sequence
+
+import pyarrow as pa
+import pyarrow.compute as pc
+
+from pyspark import cloudpickle
+from pyspark.errors import PySparkRuntimeError
+from pyspark.util import _format_exception
+
+_UDF_TRACEBACK_SENTINEL = "__INPROCESS_UDF_TRACEBACK__:"
+NullChecker = Callable[[pa.Array], None]
+
+
+class _Registration(NamedTuple):
+    func: Callable[..., pa.Array]
+    expected_type: pa.DataType
+    # The expected type as _nullable_type normalizes it, to compare result 
types with.
+    expected_key: pa.DataType
+    checker: NullChecker
+    hide_traceback: bool
+    simplified_traceback: bool
+    traceback_with_locals: bool
+    full_validation: bool
+
+
+_udfs: dict[str, _Registration] = {}
+# Pin exported buffers until the task has released its CDI references. This 
keeps Python
+# finalizers on the interpreter thread, including for NumPy-backed results.
+_results: dict[str, pa.Array] = {}
+
+
+def _jep_safe_message(message: str) -> str:
+    # JNI modified UTF-8 agrees with UTF-8 for BMP characters except 
NUL/surrogates.
+    return re.sub(
+        r"[\x00\ud800-\udfff\U00010000-\U0010ffff]",
+        lambda match: match.group().encode("unicode_escape").decode("ascii"),
+        message,
+    )
+
+
+def _inprocess_register(
+    handle: str,
+    serialized_udf: Any,
+    schema_ptr: int,
+    python_version: str,
+    hide_traceback: bool = False,
+    simplified_traceback: bool = False,
+    traceback_with_locals: bool = False,
+    full_validation: bool = True,
+) -> None:
+    try:
+        # The interpreter's bootstrap has checked the PyArrow version once for 
its lifetime.
+        embedded_version = "%d.%d" % sys.version_info[:2]
+        if python_version != embedded_version:
+            raise PySparkRuntimeError(

Review Comment:
   Done in 8aead4b: registration raises a new 
`IN_PROCESS_PYTHON_VERSION_MISMATCH`, whose message says that the interpreter 
is the CPython that JEP was built against, which `PYSPARK_PYTHON` does not 
select, and to install JEP built for the driver's Python or run the driver with 
the embedded version.



##########
docs/sql-pyspark-inprocess-udf.md:
##########
@@ -0,0 +1,739 @@
+---
+layout: global
+title: In-Process Python UDFs
+displayTitle: In-Process Python UDFs
+license: |
+  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.
+---
+
+* Table of contents
+{:toc}
+
+## Runtime and result contract
+
+Each executor owns a dedicated interpreter thread. The plugin initializes the
+interpreter on that thread, and task calls and shutdown are dispatched to the
+same thread. The JVM is asked to allocate an 8 MiB stack for this thread; the
+actual size is platform-dependent. Calls from concurrent tasks are queued on 
the
+interpreter thread.
+One task per executor is recommended for throughput, but is not a correctness 
requirement.
+Application-level Python parallelism comes from multiple executor JVMs.
+The plugin configures JEP's process-wide interpreter with hash seed `0`, 
matching
+Spark's default Python worker seed. It must initialize before any other JEP 
user in
+the JVM. The seed cannot change between SparkContexts in the same process; a 
custom
+worker `PYTHONHASHSEED` does not override this embedded-runtime setting.
+
+Task cancellation cannot safely stop arbitrary native Python code. An 
interrupted
+caller waits for the current invocation to finish before freeing the Arrow CDI
+structures, then restores its interrupt status. A UDF that never returns can
+therefore prevent its task from completing cancellation and block every 
subsequent
+in-process UDF on that executor, including calls from other tasks, jobs, and 
sessions.
+Recovery from a permanently hung invocation requires replacing the executor 
process.
+Plugin shutdown stops accepting new calls and waits up to five seconds for the 
interpreter thread. If a call is
+still running or a task still owns exported results, cleanup waits for that 
task to release
+its CDI references; the memory remains live until cleanup completes or the 
process exits. Shutdown does not forcibly interrupt native
+code. A new interpreter cannot start until the previous one has fully stopped.
+
+A scalar UDF must return a `pyarrow.Array` with exactly one element per input 
row.
+The runtime checks the result type against the declared Spark type, including
+nested fields, decimal scale, and timestamp unit. Timezone-aware timestamps 
are relabeled
+to `spark.sql.session.timeZone` without changing their UTC instants or copying 
their buffers.
+Timezone-naive and timezone-aware timestamps are not interchangeable. String 
and binary
+offset widths, including nested values, are converted as needed to match
+`spark.sql.execution.arrow.useLargeVarTypes`. Large, fixed-size and 
dictionary-encoded
+representations of the declared types (`large_list`, `fixed_size_list`, 
`string_view`,
+`binary_view`, `fixed_size_binary` and dictionary arrays) are cast to the 
declared type.
+These conversions can allocate new buffers. Other value types must match 
exactly: use an
+explicit PyArrow cast for numeric conversions.
+Map `keys_sorted` metadata is normalized to Spark's declared map type.
+Nested field nullability may differ if the actual values satisfy the declared 
nullability. Sliced results, including nested
+child slices, are copied to remove offsets that Arrow Java's CDI importer 
cannot
+read. Zero-length levels without a usable offsets buffer, which Arrow permits, 
are given
+one. Compatible results retain zero-copy transfer.
+Before exporting a result, the runtime performs full Arrow validation, 
including interior
+offsets, because the JVM reads result buffers without bounds checks: a 
malformed result,
+such as one built from raw buffers, could otherwise produce wrong values or 
crash the
+executor. It does not validate UTF-8 in string results, because Spark strings 
may contain
+invalid UTF-8 (for example, `CAST(X'FF' AS STRING)`). Worker-based Arrow UDFs 
do not
+validate their results. To skip the full validation, set
+`spark.sql.execution.pythonUDF.inProcess.fullValidation.enabled` to `false`; 
Arrow's
+constant-time validation and the conversions above still apply.
+
+The API produces a regular `PythonUDF` expression with an in-process evaluation
+type. Spark's existing `ArrowEvalPython` planning rules handle aggregation,
+nested calls, nondeterminism, and filter/limit pushdown. A dedicated
+`InProcessArrowEvalPythonExec` extends `EvalPythonExec`, reusing its argument 
extraction
+and partition-evaluator path, while its evaluator buffers and joins input rows 
itself.
+Ordinary Python UDFs continue to use Python workers. Unlike them, in-process 
UDFs cannot be
+called inside the lambda of a higher-order function such as `transform` or 
`filter`; such a
+call fails analysis with `UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF`.
+
+`maxRecordsPerBatch <= 0` means no row-count limit. The independent
+`spark.sql.execution.arrow.maxBytesPerBatch` limit always applies.
+Only UDF arguments are converted to Arrow. Other columns stay in Spark rows,
+buffered in a spillable queue until the results are joined back. When every 
input
+column is a UDF argument and its type, other than a decimal, an array or a 
map, reads back
+from Arrow unchanged, the output reads those columns from the Arrow input 
vectors instead
+of buffering the rows.
+Duplicate nested field names in UDF arguments or declared results are rejected 
before
+Arrow Java reads their buffers.
+
+Each batch uses fresh input buffers. A Python function may retain an input 
array;
+later batches do not overwrite it. Retained arrays keep native memory alive, so
+functions should release them when no longer needed. JVM input vectors and 
result
+vectors are released on task completion, early termination and failure. The 
runtime retains
+each exported result until the next invocation for that task or task cleanup, 
after the JVM
+has released its references. The runtime drops its Python references on the 
interpreter
+thread, so releasing JVM results does not trigger Python finalizers on Spark 
task threads.
+Cleanup can remain queued behind another task's invocation. The rows can also 
be consumed
+on another thread, such as a pipelined Python worker's writer. Task completion 
then stops
+that consumer after the input row it is reading, and waits for it, but not for 
this
+operator's Python: it releases the buffered rows at once, and the Arrow 
vectors when Python
+returns. Reading one row can take longer when the input is another in-process 
UDF, whose
+next row may need a batch of Python, or a blocked upstream operator. While the 
consumer
+reads its input, task completion waits for at most one second, and then leaves 
the buffered
+rows to the executor and deletes their spill files. For a task that otherwise 
succeeds, the
+executor then logs "Managed memory leak detected", or fails the task if
+`spark.unsafe.exceptionOnMemoryLeak` is `true`. Each row is copied before it 
is returned, so
+it remains valid after the task releases them.
+
+UDF deserialization uses PySpark's bundled cloudpickle. Each task registers its
+own function instance once and passes a small handle for subsequent batches.
+Exception text escapes NUL, surrogates and non-BMP characters for JEP's JNI 
exception
+transport. Other characters, including non-English BMP text, remain readable.
+Task completion queues release of the registered function and its closure 
state. Imported
+Python modules still share executor-wide state. Configured site-packages paths
+are supplied to JEP before its first construction, so JEP itself can be found 
in
+an archived venv. They are then processed with `site.addsitedir`, including 
`.pth`
+files. Spark's own PySpark and Py4J distribution paths and the process 
`PYTHONPATH`
+come first, followed by configured directories, newly discovered `.pth` paths,
+and existing system paths.
+Already imported modules cannot be replaced by changing the search path.
+
+The embedded interpreter uses isolated Python initialization and ignores 
Python startup
+flags from the environment (including `PYTHONFAULTHANDLER` and 
`PYTHONDEVMODE`). Spark
+explicitly restores its Python distribution paths and the executor process 
`PYTHONPATH`
+before configured `sitePackages` paths. This also supports YARN's localized 
Python archives
+when `SPARK_HOME` is absent. Python modules must not install process-wide 
signal handlers

Review Comment:
   Added in 8aead4b to that paragraph: UDF code should not start processes, 
with the examples you list, since a forked child copies the multi-threaded 
executor JVM and a spawned one runs `sys.executable`, which is not a Python 
interpreter under JEP unless the code first calls 
`multiprocessing.set_executable`.



##########
python/benchmarks/README.md:
##########
@@ -84,3 +84,74 @@ class MyBenchmark:
 ```
 
 See [ASV 
documentation](https://asv.readthedocs.io/en/stable/writing_benchmarks.html) 
for more details.
+
+## In-process Python UDF benchmark
+
+`bench_inprocess_udf.InProcessUDFTimeBench` runs real Spark queries comparing
+in-process Arrow UDFs with worker Arrow UDFs (the primary baseline) and pandas
+UDFs (a supplementary baseline). Both Arrow modes accept and return PyArrow 
arrays
+and execute the same Arrow operations. It covers narrow/wide integer inputs,
+short string uppercase, and 1000-character string identity. Input construction,
+cache materialization and two warmup queries are outside timing. Each sample
+executes one query to a noop sink; normal runs request five samples.
+
+Use a Spark assembly built from the same checkout as the Python source. For 
example,
+`build/sbt -Phive package` builds Spark with Hive support. This benchmark 
requires
+the in-process UDF implementation in the JVM as well as Python; ASV's wheel 
build
+alone does not ensure that the Spark JARs match a selected commit. Start with
+`--python=same` and rebuild Spark whenever switching source versions.
+
+Activate a Python environment containing `asv`, `jep>=4.3.2`, `pyarrow`, 
`pandas` and

Review Comment:
   Fixed in 8aead4b: `jep>=4.3.2,<4.4`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to