This is an automated email from the ASF dual-hosted git repository.
viirya pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new d33352c5e40a [SPARK-56642][SQL] Add pipelined JVM-Python UDF data
transfer
d33352c5e40a is described below
commit d33352c5e40a9358636cbf7d5cef040edaf10ac8
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Thu Jun 25 14:38:36 2026 -0700
[SPARK-56642][SQL] Add pipelined JVM-Python UDF data transfer
### What changes were proposed in this pull request?
Add an opt-in pipelined execution mode for Python UDFs
(spark.python.udf.pipelined.enabled). When enabled, a dedicated writer thread
serializes input and writes directly to the Python worker socket in blocking
mode, while the task main thread reads output from the same socket
concurrently. Socket full-duplex allows both directions to overlap, achieving
pipeline parallelism.
Key changes:
- PipelinedWriterRunnable in PythonRunner.scala: serializes input +
writes to socket in a pool thread
- pipelined_process() in worker.py: background reader thread pre-fetches
input batches; lazy iterators from grouped/aggregate serializers are eagerly
materialized to avoid cross-thread socket conflicts
- InMemoryRowQueue gains a lockFree parameter: when true (pipelined mode
only), synchronized is skipped since blocking socket I/O already provides
happens-before guarantees
- Proper propagation of TaskContext and InputFileBlockHolder to the
writer pool thread
- Socket idle timeout handling via SO_TIMEOUT + SocketTimeoutException
wrapper
Benchmark: `python/pyspark/sql/tests/pandas/bench_pipelined_udf.py`
Environment: local[*] (16 cores), 5 iterations, 2 warmup
| Scenario | Sync (ms) | Pipelined (ms) | Speedup |
|------------------------------|-----------|----------------|-----------|
| Light UDF (1M rows) | 100 | 90 | 1.11x |
| CPU-bound UDF (1M rows) | 143 | 146 | 0.98x |
| Heavy UDF (10ms sleep/batch) | 1180 | 1176 | 1.00x |
| Large data (5M rows) | 338 | 300 | **1.13x** |
| Multi-UDF (3 columns, 1M) | 123 | 100 | **1.24x** |
ASV Benchmark: `python/benchmarks/bench_pipelined_udf.py`
**ScalarUDFTimeBench** (scalar UDF `x + 1`)
| pipelined | 100K rows | 1M rows |
|-----------|-----------|---------|
| False | 122±0ms | 200±0ms |
| True | 86.5±0ms | 164±0ms |
**LargeDataUDFTimeBench** (5M rows, scalar UDF `x + 1`)
| pipelined | time | peak memory |
|-----------|------|-------------|
| False | 526±0ms | 116M |
| True | 496±0ms | 110M |
**MultiUDFTimeBench** (3 UDF columns)
| pipelined | 100K rows | 1M rows |
|-----------|-----------|---------|
| False | 157±0ms | 305±0ms |
| True | 123±0ms | 269±0ms |
Memory usage is the same for both modes (~110M).
#### Relationship to SPARK-44705
SPARK-44705 made PythonRunner single-threaded by removing the original
WriterThread. That WriterThread shared a single blocking java.net.Socket with
the main reader thread, where Thread.interrupt() does not unblock
OutputStream.write. This led to two recurring bugs:
- SPARK-33277 — race between writer-thread access to off-heap rows and
the FileScan task-completion listener freeing those rows; segfault.
- SPARK-38677 — three-way deadlock (task → writer → python → task)
because interrupt() couldn't unblock the writer's blocking socket write.
SPARK-44705 mitigated both by collapsing to one thread + SocketChannel
selector and required a few monitor threads to keep the design tractable. This
PR is not a revert — the existing single-threaded selector path remains the
default and is unchanged. The new pipelined path is opt-in
(spark.python.udf.pipelined.enabled=false by default) and is structured so the
two original bugs do not return.
| | Pre-44705 `WriterThread` | This PR's pipelined path |
| --- | --- | --- |
| Socket API | `java.net.Socket.OutputStream` — `Thread.interrupt()` does
**not** unblock `write` | `java.nio.channels.SocketChannel` —
`Thread.interrupt()` closes the channel and throws `ClosedByInterruptException`
|
| Cancel + cleanup | `interrupt() + join()`; could deadlock (SPARK-38677),
needed a `WriterMonitorThread` to break it | `Future.cancel(true) +
Future.get()`; `cancel(true)` reliably unblocks the writer, `get()` then
provides the same SPARK-33277 ordering guarantee as `join()`, no monitor thread
required |
A regression test mirroring the SPARK-33277 reproducer
(`test_offheap_reader_with_head_does_not_segfault`) is included.
### Why are the changes needed?
The current single-threaded NIO selector model serializes input and reads
output in the same thread alternately. For multi-column UDFs or compute-heavy
UDFs, this leaves the JVM idle while waiting for Python output. Pipelined mode
overlaps serialization with output reading, improving throughput by up to 22%
for multi-UDF queries and 10% for large data workloads.
### Does this PR introduce _any_ user-facing change?
No
### How was this patch tested?
- New dedicated test suite test_pipelined_udf.py with 12 tests running
with spark.python.udf.pipelined.enabled=true via SparkConf, covering scalar
UDF, string UDF, multi-column UDF, chained UDF, null handling, UDAF, empty
partitions, multiple partitions, large data, batched UDF, and exception
propagation
- All PySpark UDF test suites pass with pipelined=true as the
compile-time default: test_pandas_udf_scalar, test_pandas_udf_grouped_agg,
test_pandas_udf_window, test_udf, test_udtf
- JVM test suites pass: PythonUDFSuite, BatchEvalPythonExecSuite,
ArrowColumnarPythonUDFSuite
- Benchmark (bench_pipelined_udf.py) across 5 scenarios confirms no
regression vs sync mode and up to 22% speedup for multi-UDF workloads
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
Closes #55552 from viirya/pipelined-python-udf.
Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
(cherry picked from commit 59d4fa29acc11361b374965e1f9bbc4cad691d31)
Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
.../org/apache/spark/api/python/PythonRunner.scala | 257 ++++++++++++++++-
.../org/apache/spark/internal/config/Python.scala | 25 ++
dev/sparktestsupport/modules.py | 1 +
python/benchmarks/bench_pipelined_udf.py | 220 +++++++++++++++
python/pyspark/sql/pandas/serializers.py | 7 +-
.../sql/tests/pandas/bench_pipelined_udf.py | 305 +++++++++++++++++++++
.../pyspark/sql/tests/pandas/test_pipelined_udf.py | 285 +++++++++++++++++++
python/pyspark/worker.py | 101 ++++++-
.../python/ArrowAggregatePythonExec.scala | 7 +-
.../python/ArrowWindowPythonEvaluatorFactory.scala | 6 +-
.../ColumnarArrowEvalPythonEvaluatorFactory.scala | 7 +-
.../python/EvalPythonEvaluatorFactory.scala | 8 +-
.../sql/execution/python/EvalPythonUDTFExec.scala | 6 +-
.../spark/sql/execution/python/RowQueue.scala | 66 ++++-
14 files changed, 1274 insertions(+), 27 deletions(-)
diff --git a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
index 896ab6e3b19d..ee15bd5e46ef 100644
--- a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
+++ b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
@@ -23,7 +23,7 @@ import java.nio.ByteBuffer
import java.nio.channels.{AsynchronousCloseException, Channels, SelectionKey,
ServerSocketChannel, SocketChannel}
import java.nio.file.{Files => JavaFiles, Path}
import java.util.UUID
-import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
+import java.util.concurrent.{CancellationException, ConcurrentHashMap,
ExecutionException, TimeUnit}
import java.util.concurrent.atomic.AtomicBoolean
import scala.jdk.CollectionConverters._
@@ -121,6 +121,21 @@ private[spark] object PythonEvalType {
private[spark] object BasePythonRunner extends Logging {
+ /**
+ * Shared thread pool for pipelined writer tasks. Using a cached thread pool
ensures that
+ * writer threads are reused across tasks, which keeps JIT-compiled code,
branch prediction
+ * history, and CPU caches warm.
+ * Bounded by executor cores since each task uses at most one writer thread.
+ */
+ private[python] lazy val pipelinedWriterThreadPool = {
+ // Each concurrent task uses at most one writer thread. Bound the pool by
available
+ // processors, which is the natural upper limit for concurrent tasks on
this executor.
+ // Using availableProcessors() instead of EXECUTOR_CORES because the
latter defaults
+ // to 1 in local[*] mode even though multiple tasks run concurrently.
+ val maxThreads = Runtime.getRuntime.availableProcessors()
+ ThreadUtils.newDaemonCachedThreadPool("python-udf-pipelined-writer",
maxThreads)
+ }
+
private[spark] lazy val faultHandlerLogDir = Utils.createTempDir(namePrefix
= "faulthandler")
private[spark] def faultHandlerLogPath(pid: Int): Path = {
@@ -211,6 +226,8 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
conf.get(PYTHON_WORKER_TRACEBACK_DUMP_INTERVAL_SECONDS)
protected val killWorkerOnFlushFailure: Boolean =
conf.get(PYTHON_DAEMON_KILL_WORKER_ON_FLUSH_FAILURE)
+ protected val pipelinedEnabled: Boolean =
conf.get(PYTHON_UDF_PIPELINED_EXECUTION)
+ protected val pipelinedQueueDepth: Int =
conf.get(PYTHON_UDF_PIPELINED_QUEUE_DEPTH)
protected val hideTraceback: Boolean = false
protected val simplifiedTraceback: Boolean = false
protected val tracebackWithLocals: Boolean = false
@@ -329,6 +346,12 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
envVars.put("SPARK_JOB_ARTIFACT_UUID",
jobArtifactUUID.getOrElse("default"))
envVars.put("SPARK_PYTHON_RUNTIME", "PYTHON_WORKER")
+ // Pipelined mode is only for UDF eval types, not NON_UDF
(mapPartitions/RDD path).
+ val usePipelined = pipelinedEnabled && evalType != PythonEvalType.NON_UDF
+ if (usePipelined) {
+ envVars.put("SPARK_PIPELINED_UDF", "1")
+ envVars.put("SPARK_PIPELINED_UDF_QUEUE_DEPTH",
pipelinedQueueDepth.toString)
+ }
val (worker: PythonWorker, handle: Option[ProcessHandle]) =
env.createPythonWorker(
pythonExec, workerModule, daemonModule, envVars.asScala.toMap, useDaemon)
@@ -363,15 +386,128 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
}
// Return an iterator that read lines from the process's stdout
- val dataIn = new DataInputStream(new BufferedInputStream(
- new ReaderInputStream(worker, writer, handle,
- faultHandlerEnabled, idleTimeoutSeconds, killOnIdleTimeout, context),
- bufferSize))
+ val dataIn = if (usePipelined) {
+ createPipelinedDataIn(worker, writer, handle, context)
+ } else {
+ new DataInputStream(new BufferedInputStream(
+ new ReaderInputStream(worker, writer, handle,
+ faultHandlerEnabled, idleTimeoutSeconds, killOnIdleTimeout, context),
+ bufferSize))
+ }
val stdoutIterator = newReaderIterator(
dataIn, writer, startTime, env, worker, handle.map(_.pid.toInt),
releasedOrClosed, context)
new InterruptibleIterator(context, stdoutIterator)
}
+ /**
+ * Sets up pipelined mode: switches the socket to blocking mode, starts the
writer
+ * thread, configures idle timeout, and returns a DataInputStream for
reading output.
+ */
+ private def createPipelinedDataIn(
+ worker: PythonWorker,
+ writer: Writer,
+ handle: Option[ProcessHandle],
+ context: TaskContext): DataInputStream = {
+ // Switch the channel to blocking mode for true full-duplex I/O.
+ // Must close the selector first because configureBlocking() fails
+ // if the channel is registered with a selector
(IllegalBlockingModeException).
+ if (worker.selectionKey != null) {
+ worker.selectionKey.cancel()
+ }
+ if (worker.selector != null) {
+ worker.selector.close()
+ }
+ worker.channel.configureBlocking(true)
+ worker.refresh() // re-initializes (no selector in blocking mode)
+
+ val writerRunnable = new PipelinedWriterRunnable(worker, writer,
bufferSize, context)
+ val writerFuture =
BasePythonRunner.pipelinedWriterThreadPool.submit(writerRunnable)
+
+ // Wait for the writer to actually exit before letting subsequent task
completion listeners
+ // run. Subsequent listeners (registered earlier, executed later under
LIFO) free off-heap
+ // memory backing the input rows; if the writer is still serializing such
a row when free
+ // happens, we get a use-after-free segfault (SPARK-33277). cancel(true)
is enough to unblock
+ // a writer stuck on channel.write (JDK closes the SocketChannel and throws
+ // ClosedByInterruptException on interrupt), so this get() returns
promptly in normal cases;
+ // the worst case is a bounded wait for the writer to finish serializing
the current row or
+ // batch and observe the interrupt flag at the top of its loop.
+ context.addTaskCompletionListener[Unit] { _ =>
+ writerFuture.cancel(true)
+ try {
+ writerFuture.get()
+ } catch {
+ case _: CancellationException | _: ExecutionException | _:
InterruptedException =>
+ // Expected: cancel(true) raced ahead, or writer exited via
_exception path.
+ }
+ }
+
+ // Set socket read timeout for idle timeout detection in pipelined mode.
+ // Always set explicitly (including 0 = no timeout) because reused workers
may
+ // retain a stale SO_TIMEOUT from a previous task that had a different
setting.
+ worker.channel.socket().setSoTimeout(
+ if (idleTimeoutSeconds > 0) idleTimeoutSeconds.toInt * 1000 else 0)
+
+ // Wrap the socket InputStream to handle idle timeout, matching sync mode
behavior:
+ // - Log warning on each timeout
+ // - If killOnIdleTimeout=true: kill worker, then throw
PythonWorkerException
+ // - If killOnIdleTimeout=false: log only, retry read (continue waiting)
+ val socketInput = new InputStream {
+ private val inner = worker.channel.socket().getInputStream
+ private var pythonWorkerKilled = false
+ override def read(): Int = doRead(() => inner.read())
+ override def read(b: Array[Byte], off: Int, len: Int): Int =
+ doRead(() => inner.read(b, off, len))
+ private def doRead(op: () => Int): Int = {
+ var result = 0
+ var retry = true
+ while (retry) {
+ try {
+ result = op()
+ retry = false
+ } catch {
+ case _: java.net.SocketTimeoutException =>
+ if (pythonWorkerKilled) {
+ logWarning(
+ log"Waiting for Python worker process to terminate after
idle timeout: " +
+ pythonWorkerStatusMessageWithContext(
+ handle, worker, hasInputs = true))
+ } else {
+ logWarning(
+ log"Idle timeout reached for Python worker (timeout: " +
+ log"${MDC(PYTHON_WORKER_IDLE_TIMEOUT, idleTimeoutSeconds)}
seconds). " +
+ log"No data received from the worker process - " +
+ pythonWorkerStatusMessageWithContext(
+ handle, worker, hasInputs = true) +
+ log" - ${MDC(TASK_NAME, taskIdentifier(context))}")
+ if (killOnIdleTimeout) {
+ handle.foreach { h =>
+ if (h.isAlive) {
+ logWarning(
+ log"Terminating Python worker process due to idle
timeout " +
+ log"(timeout: " +
+ log"${MDC(PYTHON_WORKER_IDLE_TIMEOUT,
idleTimeoutSeconds)} " +
+ log"seconds) - ${MDC(TASK_NAME,
taskIdentifier(context))}")
+ pythonWorkerKilled = h.destroy()
+ }
+ }
+ }
+ }
+ }
+ }
+ if (result == -1 && pythonWorkerKilled) {
+ val base = "Python worker process terminated due to idle timeout " +
+ s"(timeout: $idleTimeoutSeconds seconds)"
+ val msg = tryReadFaultHandlerLog(faultHandlerEnabled,
handle.map(_.pid.toInt))
+ .map(error => s"$base: $error")
+ .getOrElse(base)
+ throw new PythonWorkerException(msg)
+ }
+ result
+ }
+ }
+ new DataInputStream(new BufferedInputStream(socketInput, bufferSize))
+ }
+
protected def newWriter(
env: SparkEnv,
worker: PythonWorker,
@@ -408,6 +544,11 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
/** Contains the throwable thrown while writing the parent iterator to the
Python process. */
def exception: Option[Throwable] = Option(_exception)
+ /** Records a throwable observed by an external collaborator (e.g. the
pipelined writer). */
+ private[python] def setException(t: Throwable): Unit = {
+ _exception = t
+ }
+
/**
* Writes a command section to the stream connected to the Python worker.
*/
@@ -1000,6 +1141,112 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
}
}
+ /**
+ * A dedicated thread that serializes input data and writes it directly to
the Python worker
+ * socket in blocking mode. The task main thread simultaneously reads output
from the same
+ * socket. TCP sockets are full-duplex, so concurrent read() and write()
from different
+ * threads is safe -- they operate on independent OS-level buffers.
+ *
+ * This design achieves true pipeline parallelism without any inter-thread
queues or locks:
+ * Writer Thread: serialize batch N -> channel.write(batch N)
[blocking]
+ * Reader Thread: channel.read(output N-1)
[blocking]
+ * Python: read batch N-1 -> compute -> write output -> read
batch N
+ *
+ * Deadlock safety: Python's UDF loop is "read input -> compute -> write
output -> repeat".
+ * As long as the reader thread is consuming Python's output (freeing
Python's send buffer),
+ * Python will eventually consume input from the socket (freeing the JVM's
send buffer for
+ * the writer thread). The reader thread is always actively reading because
the task's
+ * downstream operators pull output on demand.
+ *
+ * Unlike the old WriterThread (removed in SPARK-44705), this design uses a
blocking socket
+ * in full-duplex mode rather than two threads competing on the same
blocking socket with
+ * shared mutable state. The old design's deadlocks were caused by complex
interactions
+ * with vectorized readers and monitor threads, not by the fundamental
read/write split.
+ */
+ class PipelinedWriterRunnable(
+ worker: PythonWorker,
+ writer: Writer,
+ bufferSize: Int,
+ context: TaskContext)
+ extends Runnable {
+
+ // Capture InputFileBlockHolder from the task thread so we can propagate it
+ // to the writer pool thread. This is needed because upstream scan
operators
+ // set InputFileBlockHolder via InheritableThreadLocal, but pool threads
+ // don't inherit from the task thread.
+ private val parentInputFileBlockHolder =
InputFileBlockHolder.getThreadLocalValue()
+
+ override def run(): Unit = {
+ // Propagate TaskContext and InputFileBlockHolder to the pool thread so
that
+ // upstream operators work correctly.
+ TaskContext.setTaskContext(context)
+ InputFileBlockHolder.setThreadLocalValue(parentInputFileBlockHolder)
+ val bufferStream = new DirectByteBufferOutputStream(bufferSize)
+ val dataOut = new DataOutputStream(bufferStream)
+ try {
+ // Write command/metadata (partition index, task context, broadcasts,
UDF definition).
+ writer.open(dataOut)
+ flushToSocket(bufferStream)
+
+ // Write input data in a loop, batching into buffers of ~bufferSize.
+ var hasInput = true
+ while (hasInput && !Thread.currentThread().isInterrupted) {
+ hasInput = writer.writeNextInputToStream(dataOut)
+ if (bufferStream.size() >= bufferSize || !hasInput) {
+ if (!hasInput) {
+ writer.close(dataOut)
+ }
+ flushToSocket(bufferStream)
+ }
+ }
+ } catch {
+ case _: InterruptedException =>
+ // Task cancelled via Future.cancel(true)
+ Thread.currentThread().interrupt()
+ case _: java.nio.channels.ClosedByInterruptException =>
+ // Task cancelled while blocked in channel.write(). The channel is
+ // automatically closed by the JVM, which will cause Python to
receive
+ // EOF and the reader thread to get IOException.
+ Thread.currentThread().interrupt()
+ case NonFatal(t) =>
+ // InterruptedException and ClosedByInterruptException are matched
above; what
+ // remains here is genuine non-fatal failure that needs to be
propagated to the
+ // reader through writer.exception + a socket EOF.
+ writer.setException(t)
+ // Shut down the socket output so Python receives EOF and terminates.
+ // This unblocks the reader thread which is waiting on socket input:
+ // Python will exit, closing its end of the socket, causing the
reader's
+ // read() to return -1. The ReaderIterator will then check
writer.exception
+ // and propagate the failure.
+ if (worker.channel.isConnected) {
+ Utils.tryLog(worker.channel.shutdownOutput())
+ }
+ } finally {
+ TaskContext.unset()
+ InputFileBlockHolder.unset()
+ try {
+ bufferStream.close()
+ } catch {
+ case _: Exception => // ignore
+ }
+ }
+ }
+
+ /**
+ * Writes all buffered data to the socket and resets the buffer for reuse.
+ * Uses the DirectByteBufferOutputStream's direct buffer view for zero-copy
+ * socket writes. The write() call is blocking -- it will wait until the OS
+ * socket send buffer has room, which provides natural backpressure.
+ */
+ private def flushToSocket(bufferStream: DirectByteBufferOutputStream):
Unit = {
+ val buf = bufferStream.toByteBuffer
+ while (buf.hasRemaining) {
+ worker.channel.write(buf) // blocking write
+ }
+ bufferStream.reset()
+ }
+ }
+
}
private[spark] object PythonRunner {
diff --git a/core/src/main/scala/org/apache/spark/internal/config/Python.scala
b/core/src/main/scala/org/apache/spark/internal/config/Python.scala
index dc16d1ff255d..1f5829226d88 100644
--- a/core/src/main/scala/org/apache/spark/internal/config/Python.scala
+++ b/core/src/main/scala/org/apache/spark/internal/config/Python.scala
@@ -150,4 +150,29 @@ private[spark] object Python {
.version("4.1.0")
.booleanConf
.createWithDefault(true)
+
+ val PYTHON_UDF_PIPELINED_EXECUTION =
+ ConfigBuilder("spark.python.udf.pipelined.enabled")
+ .doc("When true, enables pipelined (asynchronous) data transfer between
JVM and Python " +
+ "UDF workers. In pipelined mode, input serialization runs in a
separate writer thread " +
+ "while the main task thread reads output from the Python worker,
allowing the two " +
+ "directions to overlap. This can improve throughput for some workloads
" +
+ "(e.g., multi-column UDFs or compute-heavy UDFs like ML inference);
for light, " +
+ "single-column UDFs the overhead of the extra thread may offset the
gain.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+ .booleanConf
+ .createWithDefault(false)
+
+ val PYTHON_UDF_PIPELINED_QUEUE_DEPTH =
+ ConfigBuilder("spark.python.udf.pipelined.queueDepth")
+ .doc("The maximum number of input batches the Python worker's background
reader thread " +
+ "can pre-fetch ahead of UDF computation. A higher value allows more
overlap between " +
+ "input reading and UDF processing, at the cost of increased memory
usage. " +
+ "Only effective when spark.python.udf.pipelined.enabled is true.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+ .intConf
+ .checkValue(_ > 0, "Queue depth must be positive.")
+ .createWithDefault(2)
}
diff --git a/dev/sparktestsupport/modules.py b/dev/sparktestsupport/modules.py
index c474b87c8b36..860158b941f6 100644
--- a/dev/sparktestsupport/modules.py
+++ b/dev/sparktestsupport/modules.py
@@ -605,6 +605,7 @@ pyspark_sql = Module(
"pyspark.sql.tests.pandas.test_pandas_udf",
"pyspark.sql.tests.pandas.test_pandas_udf_grouped_agg",
"pyspark.sql.tests.pandas.test_pandas_udf_scalar",
+ "pyspark.sql.tests.pandas.test_pipelined_udf",
"pyspark.sql.tests.pandas.test_pandas_udf_typehints",
"pyspark.sql.tests.pandas.test_pandas_udf_typehints_with_future_annotations",
"pyspark.sql.tests.pandas.test_pandas_udf_window",
diff --git a/python/benchmarks/bench_pipelined_udf.py
b/python/benchmarks/bench_pipelined_udf.py
new file mode 100644
index 000000000000..31eb9d0b16e3
--- /dev/null
+++ b/python/benchmarks/bench_pipelined_udf.py
@@ -0,0 +1,220 @@
+#
+# 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.
+#
+
+"""
+End-to-end benchmarks for pipelined vs synchronous Python UDF execution.
+
+Unlike the microbenchmarks in bench_eval_type.py (which test the Python worker
+in isolation), these benchmarks run full Spark queries through a real
+SparkSession to measure the JVM-Python socket I/O pipeline overlap.
+"""
+
+import pandas as pd
+
+from pyspark import SparkConf
+from pyspark.sql import SparkSession
+from pyspark.sql.functions import col, pandas_udf
+from pyspark.sql.types import LongType, StringType
+
+
+class _PipelinedUDFBenchBase:
+ """Base class for pipelined UDF benchmarks.
+
+ Each benchmark parameterizes over pipelined=true/false to compare
+ the two execution modes. SparkSession is created in setup() and
+ stopped in teardown() because spark.python.udf.pipelined.enabled
+ is a SparkConf-level config.
+ """
+
+ # Subclasses must define timeout (seconds per benchmark iteration).
+ timeout = 120
+
+ def _spark_conf(self, pipelined):
+ return (
+ SparkConf()
+ .setMaster("local[1]")
+ .setAppName("PipelinedUDFBench")
+ .set("spark.sql.execution.arrow.pyspark.enabled", "true")
+ .set("spark.python.worker.reuse", "true")
+ .set("spark.ui.enabled", "false")
+ .set("spark.sql.shuffle.partitions", "1")
+ .set("spark.python.udf.pipelined.enabled", str(pipelined).lower())
+ )
+
+ def _setup_spark(self, pipelined):
+ conf = self._spark_conf(pipelined)
+ self.spark = SparkSession.builder.config(conf=conf).getOrCreate()
+
+ @pandas_udf(LongType())
+ def _add_one(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ self._add_one = _add_one
+
+ # Warmup: start Python worker, JIT
+
self.spark.range(100).select(_add_one(col("id"))).write.format("noop").mode(
+ "overwrite"
+ ).save()
+
+ def _teardown_spark(self):
+ if hasattr(self, "spark"):
+ self.spark.stop()
+ # Clear the active session so the next setup() creates a fresh one
+ SparkSession.builder._options = {}
+
+
+class ScalarUDFTimeBench(_PipelinedUDFBenchBase):
+ """Benchmark scalar Arrow UDF with light computation (x + 1)."""
+
+ params = [[False, True], [100000, 1000000]]
+ param_names = ["pipelined", "n_rows"]
+
+ def setup(self, pipelined, n_rows):
+ self._setup_spark(pipelined)
+
+ def teardown(self, pipelined, n_rows):
+ self._teardown_spark()
+
+ def time_scalar_udf(self, pipelined, n_rows):
+
self.spark.range(n_rows).select(self._add_one(col("id")).alias("result")).write.format(
+ "noop"
+ ).mode("overwrite").save()
+
+ def peakmem_scalar_udf(self, pipelined, n_rows):
+
self.spark.range(n_rows).select(self._add_one(col("id")).alias("result")).write.format(
+ "noop"
+ ).mode("overwrite").save()
+
+
+class MultiUDFTimeBench(_PipelinedUDFBenchBase):
+ """Benchmark multiple UDF columns in a single query."""
+
+ params = [[False, True], [100000, 1000000]]
+ param_names = ["pipelined", "n_rows"]
+
+ def setup(self, pipelined, n_rows):
+ self._setup_spark(pipelined)
+
+ @pandas_udf(LongType())
+ def _mul_two(x: pd.Series) -> pd.Series:
+ return x * 2
+
+ @pandas_udf(LongType())
+ def _sub_one(x: pd.Series) -> pd.Series:
+ return x - 1
+
+ self._mul_two = _mul_two
+ self._sub_one = _sub_one
+
+ def teardown(self, pipelined, n_rows):
+ self._teardown_spark()
+
+ def time_multi_udf(self, pipelined, n_rows):
+ self.spark.range(n_rows).select(
+ col("id"),
+ self._add_one(col("id")).alias("a"),
+ self._mul_two(col("id")).alias("b"),
+ self._sub_one(col("id")).alias("c"),
+ ).write.format("noop").mode("overwrite").save()
+
+ def peakmem_multi_udf(self, pipelined, n_rows):
+ self.spark.range(n_rows).select(
+ col("id"),
+ self._add_one(col("id")).alias("a"),
+ self._mul_two(col("id")).alias("b"),
+ self._sub_one(col("id")).alias("c"),
+ ).write.format("noop").mode("overwrite").save()
+
+
+class LargeDataUDFTimeBench(_PipelinedUDFBenchBase):
+ """Benchmark scalar UDF with large data to exercise throughput."""
+
+ params = [[False, True]]
+ param_names = ["pipelined"]
+
+ def setup(self, pipelined):
+ self._setup_spark(pipelined)
+
+ def teardown(self, pipelined):
+ self._teardown_spark()
+
+ def time_large_data(self, pipelined):
+
self.spark.range(5000000).select(self._add_one(col("id")).alias("result")).write.format(
+ "noop"
+ ).mode("overwrite").save()
+
+ def peakmem_large_data(self, pipelined):
+
self.spark.range(5000000).select(self._add_one(col("id")).alias("result")).write.format(
+ "noop"
+ ).mode("overwrite").save()
+
+
+class WideRowUDFTimeBench(_PipelinedUDFBenchBase):
+ """Benchmark scalar UDF with larger per-batch in-memory size.
+
+ Each row carries a wide string payload and the Arrow batch size is bumped
so
+ one batch is ~10-50 MB rather than ~80 KB. This exercises the regime that
+ Yicong-Huang asked about in the SPARK-56642 review: how does pipelined mode
+ behave when each batch is large enough that the queue's memory overhead is
+ no longer negligible?
+ """
+
+ # (pipelined, n_rows, payload_chars, records_per_batch)
+ # 50_000 rows * 1024 chars = ~50 MB raw per dataset; with records_per_batch
+ # = 10_000 that's ~10 MB per Arrow batch.
+ params = [
+ [False, True],
+ [(50_000, 1024, 10_000), (50_000, 4096, 5_000)],
+ ]
+ param_names = ["pipelined", "shape"]
+
+ def setup(self, pipelined, shape):
+ n_rows, payload_chars, records_per_batch = shape
+ self._setup_spark(pipelined)
+ self.spark.conf.set("spark.sql.execution.arrow.maxRecordsPerBatch",
str(records_per_batch))
+ self._n_rows = n_rows
+ self._payload_chars = payload_chars
+
+ @pandas_udf(StringType())
+ def _wide_passthrough(s: pd.Series) -> pd.Series:
+ # Non-trivial work proportional to row width so the UDF actually
+ # holds a batch for a measurable amount of time.
+ return s.str.upper()
+
+ self._wide_udf = _wide_passthrough
+
+ def teardown(self, pipelined, shape):
+ self._teardown_spark()
+
+ def _make_df(self):
+ chars = self._payload_chars
+
+ @pandas_udf(StringType())
+ def _make_payload(x: pd.Series) -> pd.Series:
+ return pd.Series(["x" * chars] * len(x))
+
+ return
self.spark.range(self._n_rows).select(_make_payload(col("id")).alias("payload"))
+
+ def time_wide_row_udf(self, pipelined, shape):
+
self._make_df().select(self._wide_udf(col("payload")).alias("result")).write.format(
+ "noop"
+ ).mode("overwrite").save()
+
+ def peakmem_wide_row_udf(self, pipelined, shape):
+
self._make_df().select(self._wide_udf(col("payload")).alias("result")).write.format(
+ "noop"
+ ).mode("overwrite").save()
diff --git a/python/pyspark/sql/pandas/serializers.py
b/python/pyspark/sql/pandas/serializers.py
index d6bf3b7d7416..20e1f80a99b4 100644
--- a/python/pyspark/sql/pandas/serializers.py
+++ b/python/pyspark/sql/pandas/serializers.py
@@ -127,9 +127,10 @@ class ArrowStreamSerializer(Serializer):
output batch. Default False.
"""
- def __init__(self, write_start_stream: bool = False) -> None:
+ def __init__(self, write_start_stream: bool = False, flush_per_batch: bool
= False) -> None:
super().__init__()
self._write_start_stream: bool = write_start_stream
+ self._flush_per_batch: bool = flush_per_batch
def dump_stream(self, iterator: Iterable["pa.RecordBatch"], stream:
IO[bytes]) -> None:
"""Optionally prepend START_ARROW_STREAM, then write batches."""
@@ -144,6 +145,10 @@ class ArrowStreamSerializer(Serializer):
if writer is None:
writer = pa.RecordBatchStreamWriter(stream, batch.schema)
writer.write_batch(batch)
+ # In pipelined mode, flush after each batch so the JVM can
read output
+ # while still sending input, rather than buffering all output.
+ if self._flush_per_batch:
+ stream.flush()
finally:
if writer is not None:
writer.close()
diff --git a/python/pyspark/sql/tests/pandas/bench_pipelined_udf.py
b/python/pyspark/sql/tests/pandas/bench_pipelined_udf.py
new file mode 100644
index 000000000000..5deed766cbce
--- /dev/null
+++ b/python/pyspark/sql/tests/pandas/bench_pipelined_udf.py
@@ -0,0 +1,305 @@
+#
+# 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.
+#
+
+"""
+Benchmark: Pipelined vs synchronous JVM-Python UDF data transfer.
+
+Compares end-to-end execution time of Python UDFs with
+spark.python.udf.pipelined.enabled = true vs false.
+
+Because spark.python.udf.pipelined.enabled is a SparkConf-level config (read at
+SparkContext startup), each benchmark scenario runs in a separate subprocess
with
+its own SparkSession to ensure the config takes effect.
+
+Note: In local[1] mode (single core), pipelined mode may show overhead because
+the writer thread and selector thread compete for the same CPU. The benefit of
+pipeline parallelism is expected on multi-core executors where serialization
can
+overlap with output reading.
+
+Usage:
+ cd $SPARK_HOME
+ # Build Spark first (needed for PySpark to find JVM jars):
+ # build/sbt -Phive package
+ # cd python && zip -r lib/pyspark.zip pyspark && cd ..
+ python python/pyspark/sql/tests/pandas/bench_pipelined_udf.py \
+ [--rows N] [--iterations N] [--partitions N] [--sleep-ms N]
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+
+SPARK_HOME = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"../../../../..")
+PIPELINED_CONF = "spark.python.udf.pipelined.enabled"
+QUEUE_DEPTH_CONF = "spark.python.udf.pipelined.queueDepth"
+
+
+# ---- Subprocess worker script template ----
+# Each benchmark scenario is run in a fresh Python process to get a fresh
SparkContext.
+WORKER_TEMPLATE = """
+import os, sys, time, json
+sys.path.insert(0, "{spark_home}")
+
+import pandas as pd
+from pyspark.sql import SparkSession
+from pyspark.sql.functions import pandas_udf, col
+from pyspark.sql.types import LongType
+
+spark = (
+ SparkSession.builder.master("{master}")
+ .appName("PipelinedUDFBench")
+ .config("spark.sql.execution.arrow.pyspark.enabled", "true")
+ .config("spark.python.worker.reuse", "true")
+ .config("spark.ui.enabled", "false")
+ .config("spark.sql.shuffle.partitions", "1")
+ .config("{pipelined_conf}", "{pipelined}")
+ .config("{queue_depth_conf}", "{queue_depth}")
+ .getOrCreate()
+)
+
+{udf_code}
+
+df = {make_df_code}
+
+# Warmup
+for _ in range({warmup}):
+ df.write.format("noop").mode("overwrite").save()
+
+# Timed runs
+times = []
+for _ in range({iterations}):
+ start = time.perf_counter()
+ df.write.format("noop").mode("overwrite").save()
+ elapsed = time.perf_counter() - start
+ times.append(elapsed)
+
+# Output results as JSON to stdout
+print("BENCH_RESULT:" + json.dumps(times))
+spark.stop()
+"""
+
+
+def run_subprocess(pipelined, udf_code, make_df_code, args):
+ """Run a benchmark in a fresh subprocess, return list of timing results."""
+ script = WORKER_TEMPLATE.format(
+ spark_home=os.path.abspath(SPARK_HOME),
+ master=args.master,
+ pipelined_conf=PIPELINED_CONF,
+ pipelined="true" if pipelined else "false",
+ queue_depth_conf=QUEUE_DEPTH_CONF,
+ queue_depth=args.queue_depth,
+ udf_code=udf_code,
+ make_df_code=make_df_code,
+ warmup=args.warmup,
+ iterations=args.iterations,
+ )
+ env = os.environ.copy()
+ env["SPARK_HOME"] = os.path.abspath(SPARK_HOME)
+ py4j_zip = os.path.join(os.path.abspath(SPARK_HOME),
"python/lib/py4j-0.10.9.9-src.zip")
+ pyspark_path = os.path.join(os.path.abspath(SPARK_HOME), "python")
+ env["PYTHONPATH"] = f"{pyspark_path}:{py4j_zip}:" + env.get("PYTHONPATH",
"")
+
+ result = subprocess.run(
+ [sys.executable, "-c", script], capture_output=True, text=True,
env=env, timeout=600
+ )
+
+ for line in result.stdout.splitlines():
+ if line.startswith("BENCH_RESULT:"):
+ return json.loads(line[len("BENCH_RESULT:") :])
+
+ print(" ERROR: no BENCH_RESULT in output")
+ print(" STDERR (last 500 chars):", result.stderr[-500:] if result.stderr
else "<empty>")
+ return None
+
+
+def print_stats(label, times):
+ if not times:
+ print(f" {label:40s} FAILED")
+ return 0.0
+ avg = sum(times) / len(times)
+ mn = min(times)
+ mx = max(times)
+ print(
+ f" {label:40s} "
+ f"avg = {avg * 1000:8.1f} ms "
+ f"min = {mn * 1000:8.1f} ms "
+ f"max = {mx * 1000:8.1f} ms "
+ f"({len(times)} iters)"
+ )
+ return avg
+
+
+def run_benchmark(label, udf_code, make_df_code, args):
+ """Run sync and pipelined in separate subprocesses, print comparison."""
+ print(f" [{label}]")
+
+ sync_times = run_subprocess(False, udf_code, make_df_code, args)
+ sync_avg = print_stats("sync (pipelined=false)", sync_times)
+
+ pipe_times = run_subprocess(True, udf_code, make_df_code, args)
+ pipe_avg = print_stats("pipelined (pipelined=true)", pipe_times)
+
+ if pipe_avg > 0 and sync_avg > 0:
+ speedup = sync_avg / pipe_avg
+ diff_ms = (sync_avg - pipe_avg) * 1000
+ marker = "faster" if speedup > 1.0 else "slower"
+ print(f" --> pipelined is {speedup:.2f}x {marker} ({diff_ms:+.1f}
ms)")
+ print()
+ return sync_avg, pipe_avg
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Benchmark pipelined vs synchronous Python UDF data
transfer"
+ )
+ parser.add_argument(
+ "--rows",
+ type=int,
+ default=1_000_000,
+ help="Rows for standard benchmarks (default: 1000000)",
+ )
+ parser.add_argument(
+ "--large-rows",
+ type=int,
+ default=5_000_000,
+ help="Rows for large data benchmark (default: 5000000)",
+ )
+ parser.add_argument(
+ "--iterations", type=int, default=5, help="Timed iterations per
scenario (default: 5)"
+ )
+ parser.add_argument("--warmup", type=int, default=2, help="Warmup
iterations (default: 2)")
+ parser.add_argument(
+ "--partitions", type=int, default=1, help="Number of partitions
(default: 1)"
+ )
+ parser.add_argument(
+ "--sleep-ms",
+ type=float,
+ default=10.0,
+ help="Sleep time in ms per batch for heavy UDF (default: 10.0)",
+ )
+ parser.add_argument(
+ "--queue-depth", type=int, default=2, help="Pipelined queue depth
(default: 2)"
+ )
+ parser.add_argument(
+ "--master", type=str, default="local[1]", help="Spark master URL
(default: local[1])"
+ )
+ args = parser.parse_args()
+
+ nparts = args.partitions
+
+ print("=" * 78)
+ print(" Pipelined vs Synchronous Python UDF Data Transfer Benchmark")
+ print("=" * 78)
+ print(
+ f" master={args.master} rows={args.rows}
large_rows={args.large_rows} "
+ f"partitions={nparts}"
+ )
+ print(
+ f" iterations={args.iterations} warmup={args.warmup} "
+ f"sleep_ms={args.sleep_ms} queue_depth={args.queue_depth}"
+ )
+ print()
+
+ # --- Benchmark 1: Light UDF ---
+ run_benchmark(
+ "Light UDF (x + 1)",
+ udf_code="""
+@pandas_udf(LongType())
+def bench_udf(x: pd.Series) -> pd.Series:
+ return x + 1
+""",
+ make_df_code=f"spark.range({args.rows}, numPartitions={nparts})"
+ f'.select(col("id"), bench_udf(col("id")).alias("result"))',
+ args=args,
+ )
+
+ # --- Benchmark 2: CPU-bound UDF ---
+ run_benchmark(
+ "CPU-bound UDF (iterative computation)",
+ udf_code="""
+@pandas_udf(LongType())
+def bench_udf(x: pd.Series) -> pd.Series:
+ result = x + 1
+ for _ in range(20):
+ result = result + (x % 7) - 3
+ return result
+""",
+ make_df_code=f"spark.range({args.rows}, numPartitions={nparts})"
+ f'.select(col("id"), bench_udf(col("id")).alias("result"))',
+ args=args,
+ )
+
+ # --- Benchmark 3: Heavy UDF (sleep) ---
+ run_benchmark(
+ f"Heavy UDF ({args.sleep_ms}ms sleep/batch)",
+ udf_code=f"""
+import time as _time
+@pandas_udf(LongType())
+def bench_udf(x: pd.Series) -> pd.Series:
+ _time.sleep({args.sleep_ms / 1000.0})
+ return x + 1
+""",
+ make_df_code=f"spark.range({args.rows}, numPartitions={nparts})"
+ f'.select(col("id"), bench_udf(col("id")).alias("result"))',
+ args=args,
+ )
+
+ # --- Benchmark 4: Large data ---
+ run_benchmark(
+ f"Large data ({args.large_rows} rows, x + 1)",
+ udf_code="""
+@pandas_udf(LongType())
+def bench_udf(x: pd.Series) -> pd.Series:
+ return x + 1
+""",
+ make_df_code=f"spark.range({args.large_rows}, numPartitions={nparts})"
+ f'.select(col("id"), bench_udf(col("id")).alias("result"))',
+ args=args,
+ )
+
+ # --- Benchmark 5: Multiple UDF columns ---
+ run_benchmark(
+ "Multi-UDF (3 UDF columns)",
+ udf_code="""
+@pandas_udf(LongType())
+def udf_a(x: pd.Series) -> pd.Series:
+ return x + 1
+
+@pandas_udf(LongType())
+def udf_b(x: pd.Series) -> pd.Series:
+ return x * 2
+
+@pandas_udf(LongType())
+def udf_c(x: pd.Series) -> pd.Series:
+ return x - 1
+""",
+ make_df_code=f"spark.range({args.rows}, numPartitions={nparts})"
+ f'.select(col("id"), udf_a(col("id")).alias("a"), '
+ f'udf_b(col("id")).alias("b"), udf_c(col("id")).alias("c"))',
+ args=args,
+ )
+
+ print("=" * 78)
+ print(" Benchmark complete.")
+ print("=" * 78)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/python/pyspark/sql/tests/pandas/test_pipelined_udf.py
b/python/pyspark/sql/tests/pandas/test_pipelined_udf.py
new file mode 100644
index 000000000000..1e133f6e219b
--- /dev/null
+++ b/python/pyspark/sql/tests/pandas/test_pipelined_udf.py
@@ -0,0 +1,285 @@
+#
+# 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.
+#
+
+"""
+Tests for pipelined Python UDF execution mode.
+
+These tests run with spark.python.udf.pipelined.enabled=true to verify
+correctness of the pipelined data transfer path for various UDF types.
+"""
+
+import os
+import unittest
+
+from pyspark import SparkConf
+from pyspark.sql.functions import col, pandas_udf, udf
+from pyspark.sql.types import (
+ DoubleType,
+ LongType,
+ StringType,
+ StructType,
+ StructField,
+)
+from pyspark.testing.sqlutils import ReusedSQLTestCase
+from pyspark.testing.utils import (
+ have_pandas,
+ have_pyarrow,
+ pandas_requirement_message,
+ pyarrow_requirement_message,
+)
+
+if have_pandas:
+ import pandas as pd
+
+
[email protected](
+ not have_pandas or not have_pyarrow,
+ pandas_requirement_message or pyarrow_requirement_message,
+)
+class PipelinedUDFTests(ReusedSQLTestCase):
+ """Tests that run with pipelined mode enabled."""
+
+ @classmethod
+ def conf(cls):
+ return (
+ SparkConf()
+ .set("spark.python.udf.pipelined.enabled", "true")
+ .set("spark.sql.execution.arrow.pyspark.enabled", "true")
+ )
+
+ def test_pipelined_mode_is_active(self):
+ """Verify the pipelined code path is actually being used."""
+
+ @pandas_udf(StringType())
+ def check_env(x: pd.Series) -> pd.Series:
+ # SPARK_PIPELINED_UDF is set by the JVM when pipelined mode is
enabled.
+ flag = os.environ.get("SPARK_PIPELINED_UDF", "not_set")
+ return pd.Series([flag] * len(x))
+
+ result = self.spark.range(1).select(check_env(col("id"))).first()[0]
+ self.assertEqual(result, "1", "JVM should set SPARK_PIPELINED_UDF=1")
+
+ def test_scalar_arrow_udf(self):
+ """Basic scalar Arrow UDF with pipelined mode."""
+
+ @pandas_udf(LongType())
+ def add_one(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ result = (
+ self.spark.range(100).select(col("id"),
add_one(col("id")).alias("result")).collect()
+ )
+ for row in result:
+ self.assertEqual(row.result, row.id + 1)
+
+ def test_scalar_arrow_udf_string(self):
+ """Scalar Arrow UDF with string type."""
+
+ @pandas_udf(StringType())
+ def to_str(x: pd.Series) -> pd.Series:
+ return x.astype(str) + "_val"
+
+ result = self.spark.range(50).select(col("id"),
to_str(col("id")).alias("s")).collect()
+ for row in result:
+ self.assertEqual(row.s, f"{row.id}_val")
+
+ def test_multiple_udf_columns(self):
+ """Multiple UDF columns in a single query."""
+
+ @pandas_udf(LongType())
+ def udf_a(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ @pandas_udf(LongType())
+ def udf_b(x: pd.Series) -> pd.Series:
+ return x * 2
+
+ @pandas_udf(LongType())
+ def udf_c(x: pd.Series) -> pd.Series:
+ return x - 1
+
+ result = (
+ self.spark.range(100)
+ .select(
+ col("id"),
+ udf_a(col("id")).alias("a"),
+ udf_b(col("id")).alias("b"),
+ udf_c(col("id")).alias("c"),
+ )
+ .collect()
+ )
+ for row in result:
+ self.assertEqual(row.a, row.id + 1)
+ self.assertEqual(row.b, row.id * 2)
+ self.assertEqual(row.c, row.id - 1)
+
+ def test_multiple_partitions(self):
+ """UDF across multiple partitions."""
+
+ @pandas_udf(LongType())
+ def add_one(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ result = (
+ self.spark.range(1000, numPartitions=4)
+ .select(col("id"), add_one(col("id")).alias("result"))
+ .collect()
+ )
+ self.assertEqual(len(result), 1000)
+ for row in result:
+ self.assertEqual(row.result, row.id + 1)
+
+ def test_empty_partition(self):
+ """UDF with some empty partitions."""
+
+ @pandas_udf(LongType())
+ def add_one(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ # 2 rows across 4 partitions means some partitions are empty
+ result = (
+ self.spark.range(2, numPartitions=4)
+ .select(col("id"), add_one(col("id")).alias("result"))
+ .collect()
+ )
+ self.assertEqual(len(result), 2)
+ for row in result:
+ self.assertEqual(row.result, row.id + 1)
+
+ def test_chained_udf(self):
+ """Chained UDF calls."""
+
+ @pandas_udf(LongType())
+ def add_one(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ @pandas_udf(LongType())
+ def double_it(x: pd.Series) -> pd.Series:
+ return x * 2
+
+ result = (
+
self.spark.range(50).select(double_it(add_one(col("id"))).alias("result")).collect()
+ )
+ for row in result:
+ self.assertEqual(row.result, (row.result // 2) * 2) # even number
+
+ def test_udf_with_null(self):
+ """UDF handling null values."""
+
+ @pandas_udf(LongType())
+ def add_one(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ df = self.spark.createDataFrame(
+ [(1,), (None,), (3,), (None,), (5,)],
+ schema=StructType([StructField("v", LongType(), True)]),
+ )
+ result = df.select(col("v"),
add_one(col("v")).alias("result")).collect()
+ expected = [(1, 2), (None, None), (3, 4), (None, None), (5, 6)]
+ for row, (v, r) in zip(result, expected):
+ self.assertEqual(row.v, v)
+ self.assertEqual(row.result, r)
+
+ def test_grouped_agg_udf(self):
+ """Grouped aggregation UDF (UDAF) with pipelined mode."""
+
+ @pandas_udf(DoubleType())
+ def mean_udf(x: pd.Series) -> float:
+ return float(x.mean())
+
+ df = self.spark.range(100).selectExpr("id", "id % 5 as grp")
+ result =
df.groupBy("grp").agg(mean_udf(col("id")).alias("avg")).orderBy("grp").collect()
+
+ self.assertEqual(len(result), 5)
+ # grp=0: mean of 0,5,10,...,95 = 47.5
+ self.assertAlmostEqual(result[0].avg, 47.5)
+
+ def test_scalar_udf_large_data(self):
+ """Scalar UDF with large data to exercise backpressure."""
+
+ @pandas_udf(LongType())
+ def add_one(x: pd.Series) -> pd.Series:
+ return x + 1
+
+ result = (
+ self.spark.range(500000)
+ .select(add_one(col("id")).alias("result"))
+ .agg({"result": "sum"})
+ .collect()
+ )
+ # sum of (1..500000) = 500000 * 500001 / 2 = 125000250000
+ self.assertEqual(result[0][0], 125000250000)
+
+ def test_batched_udf(self):
+ """Non-Arrow batched UDF (pickle serialization)."""
+
+ @udf(StringType())
+ def simple_udf(x):
+ return str(x) + "_done"
+
+ result = self.spark.range(50).select(col("id"),
simple_udf(col("id")).alias("s")).collect()
+ for row in result:
+ self.assertEqual(row.s, f"{row.id}_done")
+
+ def test_udf_exception_propagation(self):
+ """UDF that raises an exception should propagate correctly."""
+
+ @pandas_udf(LongType())
+ def bad_udf(x: pd.Series) -> pd.Series:
+ raise ValueError("intentional error")
+
+ with self.assertRaisesRegex(Exception, "intentional error"):
+ self.spark.range(10).select(bad_udf(col("id"))).collect()
+
+ def test_offheap_reader_with_head_does_not_segfault(self):
+ """Regression test: SPARK-33277 reproducer adapted for pipelined mode.
+
+ With the off-heap vectorized Parquet reader, head() triggers a
+ driver-side cancel of the still-running task. The task completion
+ listener for the writer thread must wait for the writer to actually
+ exit before subsequent listeners free off-heap memory backing the
+ rows the writer is still serializing. Otherwise the writer reads
+ invalidated off-heap memory and the executor segfaults.
+
+ The race is probabilistic (the SPARK-33277 PR reported ~3% failure
+ rate without the fix), so we repeat the head() many times to make
+ the test reliably crash without the fix while still finishing fast.
+ """
+ import shutil
+ import tempfile
+
+ path = tempfile.mkdtemp()
+ shutil.rmtree(path)
+ try:
+ self.spark.range(0, 200000, 1, 1).write.parquet(path)
+
+ @pandas_udf(LongType())
+ def to_zero(x: pd.Series) -> pd.Series:
+ return pd.Series([0] * len(x))
+
+ with self.sql_conf({"spark.sql.columnVector.offheap.enabled":
"true"}):
+ for _ in range(100):
+ row =
self.spark.read.parquet(path).select(to_zero(col("id"))).head()
+ self.assertEqual(row[0], 0)
+ finally:
+ shutil.rmtree(path, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ from pyspark.testing import main
+
+ main()
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 5a82d11ebe9b..5ceebca15338 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -26,6 +26,7 @@ import time
import inspect
import itertools
import json
+import warnings
from collections.abc import Iterator
from typing import (
Any,
@@ -3731,12 +3732,108 @@ def invoke_udf(message_receiver: SparkMessageReceiver,
outfile: BinaryIO):
if hasattr(out_iter, "close"):
out_iter.close()
+ def pipelined_process():
+ """
+ Pipelined variant of process() that pre-fetches input batches in a
background
+ reader thread while the main thread computes the UDF and writes
output.
+ This allows input deserialization to overlap with UDF computation.
+ """
+ import queue
+ import threading
+
+ queue_depth =
int(os.environ.get("SPARK_PIPELINED_UDF_QUEUE_DEPTH", "2"))
+ _SENTINEL = object()
+ input_queue = queue.Queue(maxsize=queue_depth)
+ reader_error = [None]
+ # Event to signal the reader thread to stop (set by main thread on
+ # exception or completion). The reader checks this after each
failed
+ # put attempt instead of polling with a timeout.
+ stop_event = threading.Event()
+
+ def _reader_thread():
+ try:
+ for batch in deserializer.load_stream(input_data_stream):
+ # Some serializers (e.g., ArrowStreamGroupSerializer,
+ # ArrowStreamAggPandasUDFSerializer) yield lazy
iterators
+ # that still read from the input stream. Materialize
them here so
+ # the main thread can consume them without touching
the stream.
+ if hasattr(batch, "__next__"):
+ batch = list(batch)
+ # Block on put, but wake up when stop_event is set.
+ # stop_event.wait() returns immediately if already set.
+ while not stop_event.is_set():
+ try:
+ input_queue.put(batch, timeout=0.1)
+ break
+ except queue.Full:
+ continue
+ if stop_event.is_set():
+ return
+ except Exception as e:
+ reader_error[0] = e
+ finally:
+ # Enqueue sentinel so the consumer knows we're done.
+ while not stop_event.is_set():
+ try:
+ input_queue.put(_SENTINEL, timeout=0.1)
+ break
+ except queue.Full:
+ continue
+
+ t = threading.Thread(
+ target=_reader_thread, name="pyspark-pipelined-reader",
daemon=True
+ )
+ t.start()
+
+ def _queued_iter():
+ while True:
+ item = input_queue.get()
+ if item is _SENTINEL:
+ if reader_error[0] is not None:
+ raise reader_error[0]
+ return
+ yield item
+
+ out_iter = func(init_info.split_index, _queued_iter())
+ try:
+ serializer.dump_stream(out_iter, outfile)
+ finally:
+ if hasattr(out_iter, "close"):
+ out_iter.close()
+ # Signal reader thread to stop, drain the queue so it can
unblock,
+ # then wait for it to finish.
+ stop_event.set()
+ try:
+ while not input_queue.empty():
+ input_queue.get_nowait()
+ except Exception:
+ pass
+ # If the reader is still blocked in input_data_stream.read(),
the stop_event
+ # check only fires between put attempts -- it cannot interrupt
a syscall.
+ # Force-closing the stream here would break worker reuse (the
next task uses
+ # the same socket fd), so we settle for a bounded join and a
loud warning
+ # so an undetected leak shows up in the worker log.
+ t.join(timeout=5)
+ if t.is_alive():
+ warnings.warn(
+ "pipelined reader thread did not exit within 5s; "
+ "it may still be blocked in input_data_stream.read()
and could "
+ "read data intended for a subsequent reused-worker
task. "
+ "Consider disabling spark.python.worker.reuse if this
recurs.",
+ RuntimeWarning,
+ )
+
+ is_pipelined = os.environ.get("SPARK_PIPELINED_UDF") == "1"
+ if is_pipelined and hasattr(serializer, "_flush_per_batch"):
+ serializer._flush_per_batch = True
+ run_process = pipelined_process if is_pipelined else process
+
processing_start_time = time.time()
with capture_outputs():
if profiler:
- profiler.profile(process)
+ profiler.profile(run_process)
else:
- process()
+ run_process()
processing_time_ms = int(1000 * (time.time() - processing_start_time))
# Cleanup
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowAggregatePythonExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowAggregatePythonExec.scala
index b265d1de54b6..09bfe6538420 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowAggregatePythonExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowAggregatePythonExec.scala
@@ -23,6 +23,7 @@ import scala.collection.mutable.ArrayBuffer
import org.apache.spark.{JobArtifactSet, SparkEnv, SparkException, TaskContext}
import org.apache.spark.api.python.{ChainedPythonFunctions, PythonEvalType}
+import org.apache.spark.internal.config.Python.PYTHON_UDF_PIPELINED_EXECUTION
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions._
@@ -176,8 +177,12 @@ case class ArrowAggregatePythonExec(
// The queue used to buffer input rows so we can drain it to
// combine input with output from Python.
+ // In pipelined mode the queue's add() runs in the writer thread and
remove() runs in
+ // the task thread; use lock-free mode to skip per-row synchronization.
+ val pipelined = SparkEnv.get.conf.get(PYTHON_UDF_PIPELINED_EXECUTION)
val queue = HybridRowQueue(context.taskMemoryManager(),
- new File(Utils.getLocalDir(SparkEnv.get.conf)),
groupingExpressions.length)
+ new File(Utils.getLocalDir(SparkEnv.get.conf)),
groupingExpressions.length,
+ lockFree = pipelined)
context.addTaskCompletionListener[Unit] { _ =>
queue.close()
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
index 5ba47db7fc94..ab9671c022f9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
@@ -24,6 +24,7 @@ import scala.jdk.CollectionConverters._
import org.apache.spark.{JobArtifactSet, PartitionEvaluator,
PartitionEvaluatorFactory, SparkEnv, TaskContext}
import org.apache.spark.api.python.ChainedPythonFunctions
+import org.apache.spark.internal.config.Python.PYTHON_UDF_PIPELINED_EXECUTION
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference, BoundReference, EmptyRow, Expression, JoinedRow,
NamedArgumentExpression, NamedExpression, PythonFuncExpression, PythonUDAF,
SortOrder, SpecificInternalRow, UnsafeProjection, UnsafeRow, WindowExpression}
import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
@@ -255,8 +256,11 @@ class ArrowWindowPythonEvaluatorFactory(
// The queue used to buffer input rows so we can drain it to
// combine input with output from Python.
+ // In pipelined mode the queue's add() runs in the writer thread and
remove() runs in
+ // the task thread; use lock-free mode to skip per-row synchronization.
+ val pipelined = SparkEnv.get.conf.get(PYTHON_UDF_PIPELINED_EXECUTION)
val queue = HybridRowQueue(context.taskMemoryManager(),
- new File(Utils.getLocalDir(SparkEnv.get.conf)), childOutput.length)
+ new File(Utils.getLocalDir(SparkEnv.get.conf)), childOutput.length,
lockFree = pipelined)
context.addTaskCompletionListener[Unit] { _ =>
queue.close()
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala
index 4e699f975fec..bad609919a4b 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala
@@ -24,6 +24,7 @@ import scala.jdk.CollectionConverters._
import org.apache.spark.{PartitionEvaluator, PartitionEvaluatorFactory,
SparkEnv, TaskContext}
import org.apache.spark.api.python.ChainedPythonFunctions
+import org.apache.spark.internal.config.Python.PYTHON_UDF_PIPELINED_EXECUTION
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.errors.QueryExecutionErrors
@@ -235,10 +236,14 @@ private[python] class
ColumnarArrowEvalPythonEvaluatorFactory(
inputColumnIndices: Option[Array[Int]]
): Iterator[ColumnarBatch] = {
+ // In pipelined mode the queue's add() runs in the writer thread and
remove() runs in
+ // the task thread; use lock-free mode to skip per-row synchronization.
+ val pipelined = SparkEnv.get.conf.get(PYTHON_UDF_PIPELINED_EXECUTION)
val queue = HybridRowQueue(
context.taskMemoryManager(),
new File(Utils.getLocalDir(SparkEnv.get.conf)),
- childOutput.length)
+ childOutput.length,
+ lockFree = pipelined)
context.addTaskCompletionListener[Unit] { _ => queue.close() }
val unsafeProj = UnsafeProjection.create(
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonEvaluatorFactory.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonEvaluatorFactory.scala
index 34f9be0aa633..c0a983c60afa 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonEvaluatorFactory.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonEvaluatorFactory.scala
@@ -23,6 +23,7 @@ import scala.collection.mutable.ArrayBuffer
import org.apache.spark.{PartitionEvaluator, PartitionEvaluatorFactory,
SparkEnv, TaskContext}
import org.apache.spark.api.python.ChainedPythonFunctions
+import org.apache.spark.internal.config.Python.PYTHON_UDF_PIPELINED_EXECUTION
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.execution.python.EvalPythonExec.ArgumentMetadata
@@ -67,10 +68,15 @@ abstract class EvalPythonEvaluatorFactory(
// The queue used to buffer input rows so we can drain it to
// combine input with output from Python.
+ // In pipelined mode, add() runs in the writer thread and remove() in
the task thread.
+ // Use lock-free mode to avoid synchronized overhead (memory visibility
is guaranteed
+ // by the blocking socket I/O between the two threads).
+ val pipelined = SparkEnv.get.conf.get(PYTHON_UDF_PIPELINED_EXECUTION)
val queue = HybridRowQueue(
context.taskMemoryManager(),
new File(Utils.getLocalDir(SparkEnv.get.conf)),
- childOutput.length)
+ childOutput.length,
+ lockFree = pipelined)
context.addTaskCompletionListener[Unit] { ctx =>
queue.close()
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonUDTFExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonUDTFExec.scala
index 3cb9431fed6f..c5bad567add6 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonUDTFExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonUDTFExec.scala
@@ -22,6 +22,7 @@ import java.io.File
import scala.collection.mutable.ArrayBuffer
import org.apache.spark.{SparkEnv, TaskContext}
+import org.apache.spark.internal.config.Python.PYTHON_UDF_PIPELINED_EXECUTION
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions._
@@ -59,8 +60,11 @@ trait EvalPythonUDTFExec extends UnaryExecNode {
// The queue used to buffer input rows so we can drain it to
// combine input with output from Python.
+ // In pipelined mode the queue's add() runs in the writer thread and
remove() runs in
+ // the task thread; use lock-free mode to skip per-row synchronization.
+ val pipelined = SparkEnv.get.conf.get(PYTHON_UDF_PIPELINED_EXECUTION)
val queue = HybridRowQueue(context.taskMemoryManager(),
- new File(Utils.getLocalDir(SparkEnv.get.conf)), child.output.length)
+ new File(Utils.getLocalDir(SparkEnv.get.conf)), child.output.length,
lockFree = pipelined)
context.addTaskCompletionListener[Unit] { ctx =>
queue.close()
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/RowQueue.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/RowQueue.scala
index d2008cfa1309..9f980be51d51 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/RowQueue.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/RowQueue.scala
@@ -43,40 +43,63 @@ trait RowQueue extends Queue[UnsafeRow]
* A RowQueue that is based on in-memory page. UnsafeRows are appended into it
until it's full.
* Another thread could read from it at the same time (behind the writer).
*
+ * When `lockFree` is false (default), add() and remove() use synchronized for
thread safety.
+ * When `lockFree` is true (pipelined Python UDF mode), synchronized is
replaced by a
+ * volatile `writeOffset` using SPSC release-acquire semantics:
+ * - add() performs a volatile store on writeOffset after writing row data
(release fence),
+ * ensuring all prior Platform.putInt/copyMemory writes are visible before
the offset update.
+ * - remove() performs a volatile load on writeOffset (acquire fence) to see
the latest data.
+ * - readOffset does not need to be volatile because the writer never reads
it.
+ *
* The format of UnsafeRow in page:
* [4 bytes to hold length of record (N)] [N bytes to hold record] [...]
*
* -1 length means end of page.
*/
-private[python] abstract class InMemoryRowQueue(val page: MemoryBlock,
numFields: Int)
+private[python] abstract class InMemoryRowQueue(
+ val page: MemoryBlock, numFields: Int, lockFree: Boolean = false)
extends RowQueue {
private val base: AnyRef = page.getBaseObject
private val endOfPage: Long = page.getBaseOffset + page.size
// the first location where a new row would be written
- private var writeOffset = page.getBaseOffset
- // points to the start of the next row to read
+ // When lockFree=true, this is accessed via volatile read/write for SPSC
visibility.
+ // When lockFree=false, synchronized provides the memory barrier.
+ @volatile private var writeOffset = page.getBaseOffset
+ // points to the start of the next row to read (only updated by consumer)
private var readOffset = page.getBaseOffset
private val resultRow = new UnsafeRow(numFields)
- def add(row: UnsafeRow): Boolean = synchronized {
+ private def doAdd(row: UnsafeRow): Boolean = {
+ // Cache writeOffset in a local var to avoid repeated volatile reads in
lockFree mode.
+ val curOffset = writeOffset
val size = row.getSizeInBytes
- if (writeOffset + 4 + size > endOfPage) {
+ if (curOffset + 4 + size > endOfPage) {
// if there is not enough space in this page to hold the new record
- if (writeOffset + 4 <= endOfPage) {
+ if (curOffset + 4 <= endOfPage) {
// if there's extra space at the end of the page, store a special
"end-of-page" length (-1)
- Platform.putInt(base, writeOffset, -1)
+ Platform.putInt(base, curOffset, -1)
+ // Volatile store to publish the end-of-page marker. The reader relies
on seeing
+ // -1 to know this page is exhausted and switch to the next queue.
+ writeOffset = curOffset
}
false
} else {
- Platform.putInt(base, writeOffset, size)
- Platform.copyMemory(row.getBaseObject, row.getBaseOffset, base,
writeOffset + 4, size)
- writeOffset += 4 + size
+ Platform.putInt(base, curOffset, size)
+ Platform.copyMemory(row.getBaseObject, row.getBaseOffset, base,
curOffset + 4, size)
+ // Volatile store acts as a release fence: all prior writes (row data)
are visible
+ // to any thread that subsequently reads this writeOffset via volatile
load.
+ writeOffset = curOffset + 4 + size
true
}
}
- def remove(): UnsafeRow = synchronized {
- assert(readOffset <= writeOffset, "reader should not go beyond writer")
+ private def doRemove(): UnsafeRow = {
+ // Volatile load acts as an acquire fence: ensures all row data written by
the
+ // producer (before its volatile store of writeOffset) is visible to this
thread.
+ // Read unconditionally into a local val so the acquire fence is not
dependent on
+ // assert being enabled.
+ val curWriteOffset = writeOffset
+ assert(readOffset <= curWriteOffset, "reader should not go beyond writer")
if (readOffset + 4 > endOfPage || Platform.getInt(base, readOffset) < 0) {
null
} else {
@@ -86,6 +109,12 @@ private[python] abstract class InMemoryRowQueue(val page:
MemoryBlock, numFields
resultRow
}
}
+
+ def add(row: UnsafeRow): Boolean =
+ if (lockFree) doAdd(row) else synchronized { doAdd(row) }
+
+ def remove(): UnsafeRow =
+ if (lockFree) doRemove() else synchronized { doRemove() }
}
/**
@@ -156,7 +185,8 @@ case class HybridRowQueue(
memManager: TaskMemoryManager,
tempDir: File,
numFields: Int,
- serMgr: SerializerManager)
+ serMgr: SerializerManager,
+ lockFree: Boolean = false)
extends HybridQueue[UnsafeRow, RowQueue](memManager, tempDir, serMgr) {
override protected def createDiskQueue(): RowQueue = {
@@ -164,7 +194,7 @@ case class HybridRowQueue(
}
override protected def createInMemoryQueue(page: MemoryBlock): RowQueue = {
- new InMemoryRowQueue(page, numFields) {
+ new InMemoryRowQueue(page, numFields, lockFree) {
override def close(): Unit = {
freePage(this.page)
}
@@ -185,6 +215,14 @@ object HybridRowQueue {
HybridRowQueue(taskMemoryMgr, file, fields, SparkEnv.get.serializerManager)
}
+ def apply(
+ taskMemoryMgr: TaskMemoryManager,
+ file: File,
+ fields: Int,
+ lockFree: Boolean): HybridRowQueue = {
+ HybridRowQueue(taskMemoryMgr, file, fields,
SparkEnv.get.serializerManager, lockFree)
+ }
+
def apply(taskMemoryMgr: TaskMemoryManager, fields: Int): HybridRowQueue = {
apply(taskMemoryMgr, new File(Utils.getLocalDir(SparkEnv.get.conf)),
fields)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]