dongjoon-hyun commented on code in PR #59582:
URL: https://github.com/apache/spark/pull/59582#discussion_r4473274683


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -200,15 +231,21 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
         outputTypes: Seq[DataType],
         columnIndices: Array[Int]): Iterator[ColumnarBatch] = {
 
+      // In pipelined mode the batches are added in the writer thread, and
+      // polled by the consumer of the output, possibly on another thread.
       val passThruQueue =
-        new ArrayDeque[(Array[ColumnVector], Int)]()
-
-      val bufferedIter = inputIter.map { batch =>
-        val passThruCols = childOutput.indices.map(
-          i => batch.column(i)).toArray
-        passThruQueue.add((passThruCols, batch.numRows()))
-        batch
-      }
+        new ConcurrentLinkedQueue[(Array[ColumnVector], Int)]()
+
+      val bufferedIter = new WrittenBatchReleasingIterator(inputIter, { batch 
=>
+        val passThruCols = passThruColumns(batch)
+        if (canHold(passThruCols)) {
+          passThruQueue.add((passThruCols, batch.numRows()))
+          false
+        } else {
+          passThruQueue.add((copyColumns(passThruCols, batch.numRows()), 
batch.numRows()))

Review Comment:
   The path is chosen from the first batch only, so if only the first batch is 
retainable (like the `freshBatches` scenario in the test), the pass-through 
columns of every later batch are copied into on-heap vectors and queued in 
`passThruQueue`. The config doc mentions that they are kept without accounting 
for their memory, but nothing bounds them either: the queue holds as many 
batches as the writer runs ahead of the consumer (all of them for an iterator 
UDF that buffers its input), so wide rows and large batches may OOM the 
executor. If the same input took Path 2, `HybridRowQueue` would account for 
these rows with the task memory manager and spill them to disk. Since this risk 
is new in this PR, could we account for (or bound) the copies, or fall back to 
the row queue path instead of copying?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -200,15 +231,21 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
         outputTypes: Seq[DataType],
         columnIndices: Array[Int]): Iterator[ColumnarBatch] = {
 
+      // In pipelined mode the batches are added in the writer thread, and
+      // polled by the consumer of the output, possibly on another thread.
       val passThruQueue =
-        new ArrayDeque[(Array[ColumnVector], Int)]()
-
-      val bufferedIter = inputIter.map { batch =>
-        val passThruCols = childOutput.indices.map(
-          i => batch.column(i)).toArray
-        passThruQueue.add((passThruCols, batch.numRows()))
-        batch
-      }
+        new ConcurrentLinkedQueue[(Array[ColumnVector], Int)]()
+
+      val bufferedIter = new WrittenBatchReleasingIterator(inputIter, { batch 
=>
+        val passThruCols = passThruColumns(batch)
+        if (canHold(passThruCols)) {
+          passThruQueue.add((passThruCols, batch.numRows()))

Review Comment:
   Path 1 pairs each Python result batch with exactly one input batch 
([`passThruQueue.poll()` and `assert(passThruRows == 
numRows)`](https://github.com/apache/spark/blob/cafd4cf047c657783548bc5c39c9d150a07a7e53/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala#L264-L267)).
 However, this exec also takes `SQL_SCALAR_PANDAS_ITER_UDF` and 
`SQL_SCALAR_ARROW_ITER_UDF`, for which the worker verifies only the total row 
count (`verify_iter_result_row_count`), so the boundaries of the result batches 
may differ from those of the input batches. For example, with a retainable 
Arrow source and columnar input enabled, a valid iterator UDF like the 
following fails with `Batch size mismatch` once a partition has more than one 
input batch:
   
   ```python
   @pandas_udf("long")
   def f(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
       yield pd.concat(list(it)) + 1
   ```
   
   The row queue and row paths pair the results by row, so they are not 
affected. This is pre-existing (since SPARK-56350), but since this PR touches 
this function and documents the 1:1 assumption, could we route the iterator 
eval types to the row queue path, or at least file a JIRA to track it?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -262,7 +299,7 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
         childOutput, childOutput)
 
       val resultIter = if (inputColumnIndices.isDefined) {
-        // Path 2: simple column refs, non-Arrow vectors.
+        // Path 2: simple column refs, columns that cannot be held.

Review Comment:
   Path 2 also uses `ColumnarArrowPythonWithNamedArgumentRunner`, which 
[overrides `arrowMaxRecordsPerOutputBatch` / `arrowMaxBytesPerOutputBatch` to 
`-1`](https://github.com/apache/spark/blob/cafd4cf047c657783548bc5c39c9d150a07a7e53/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowPythonRunner.scala#L55-L58)
 because Path 1 pairs batches. Path 2 pairs the results by row 
(`queue.remove()` per output row), so slicing would be safe here, but 
`spark.sql.execution.arrow.maxRecordsPerOutputBatch` / `maxBytesPerOutputBatch` 
are silently ignored. For example, a user who sets `maxBytesPerOutputBatch` to 
bound the JVM memory for large UDF results gets the whole result of each input 
batch at once after enabling columnar input. This is pre-existing, but since no 
built-in source returns `isRetainable() == true`, Path 2 effectively becomes 
the main columnar path after this PR. Could we keep the slicing for Path 2, 
e.g., by passing whether to disable it to the runner?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -167,13 +360,14 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     // path. Both release each batch once it has been written to Python.
     for {
       codegen <- Seq(true, false)
-      (input, suffix, arrowIdColumn) <- Seq(
-        ("name", "", true), ("name", "", false), ("concat(name, '_')", "_", 
true))
+      (input, suffix, arrowIdColumn, retainable) <- Seq(
+        ("name", "", true, true), ("name", "", false, true), ("name", "", 
true, false),

Review Comment:
   The comment above says only that the batches take the row queue path if the 
`id` column is not an `ArrowColumnVector`. Could we update it for the new 
`("name", "", true, false)` case, which takes the row queue path because the 
vectors are not retainable?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -346,3 +513,9 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     }
   }
 }
+
+/** Runs the tests of [[ArrowColumnarPythonUDFSuite]] with pipelined Python 
UDF execution. */
+class ArrowColumnarPythonUDFPipelinedSuite extends ArrowColumnarPythonUDFSuite 
{

Review Comment:
   `ArrowColumnarPythonUDFPipelinedSuite` reruns the whole suite, including the 
tests that don't depend on pipelined execution. In addition, 
`takesArrowColumnarPath` / `outputColumnClasses` re-execute the query on every 
call, so the SPARK-60306 test runs each 20000-row query up to four times per 
combination, two of them only to inspect the output column classes, and this is 
repeated in both suites. Could we limit the pipelined suite to the relevant 
tests, or determine the path once per query, to reduce the CI time?



##########
sql/catalyst/src/main/java/org/apache/spark/sql/vectorized/ColumnVector.java:
##########
@@ -84,6 +84,34 @@ public void closeIfFreeable() {
     close();
   }
 
+  /**
+   * Returns whether a consumer may keep this column vector after it asks the 
producer of its batch
+   * for the next batch, until the consumer releases it with {@link 
#closeIfFreeable()}. By default,
+   * a column vector may be reused or released by its producer once the next 
batch is requested, so
+   * a consumer has to copy what it needs past that point.
+   * <p>
+   * A producer that returns new vectors for every batch can override this to 
return true, which
+   * promises that:
+   * <ul>
+   *   <li>the producer neither overwrites nor frees this vector when the next 
batch is requested,
+   *   or when the producer is closed, e.g. a {@code PartitionReader} once it 
is exhausted;</li>
+   *   <li>{@link #closeIfFreeable()} frees this vector, and may be called 
from another thread
+   *   than the one that requested its batch, concurrently with the producer 
returning the next
+   *   batches or being closed;</li>
+   *   <li>the producer frees the vectors that the consumer has not released 
when the task
+   *   completes, e.g. when the consumer stops early.</li>
+   * </ul>
+   * A consumer may keep such vectors without accounting for their memory or 
spilling them.
+   * <p>
+   * This applies to the top-level columns of a batch: the children of a 
struct, array or map
+   * column share the lifetime of that column.
+   *
+   * @since 4.4.0
+   */
+  public boolean isRetainable() {

Review Comment:
   This adds a public API with a non-trivial contract (release from another 
thread concurrently with the producer, and cleanup on task completion) for a 
single internal consumer, the `private[python]` evaluator, while no in-tree 
producer implements it, as the config doc says. So Path 1 is unreachable from 
the built-in sources, and the contract cannot be validated with a real source. 
In addition, `ReusedArrowColumnVector` already exists as the opposite "do not 
keep" marker (with a no-op `closeIfFreeable`), so we would have two overlapping 
lifetime signals. Since a public API is hard to revert, would an internal 
marker/trait be enough for now? Alternatively, we could drop Path 1 and always 
handle the pass-through columns with the row queue (or an accounted columnar 
copy).



##########
python/pyspark/sql/tests/arrow/test_arrow_python_udf_cached.py:
##########
@@ -134,8 +134,10 @@ def 
test_udf_on_cached_numeric_column_alongside_unselected_string(self):
             df.unpersist()
 
     def test_udf_on_cached_input_with_output_batch_slicing(self):
-        # The columnar path pairs each result batch with one input batch, so 
the 10-row result
-        # of the single cached batch must not be sliced under the configs 
below.
+        # The 10-row result of the single cached batch is sliced under the 
configs below, and

Review Comment:
   This comment says that the 10-row result is sliced under the configs below 
and paired by row on the row queue path. However, the row queue path uses 
`ColumnarArrowPythonWithNamedArgumentRunner`, which disables output batch 
slicing (the `-1` overrides), so the result still comes back as a single 10-row 
batch even with `maxRecordsPerOutputBatch=3`. As a result, this test doesn't 
exercise the slicing at all and would keep passing if the row-wise pairing were 
broken. Could we fix the comment, or make the row queue path honor the slicing 
(see my comment on Path 2)?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -314,6 +351,17 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
       rowsToColumnarBatches(rowIter, context)
     }
 
+    /** Copies the first `numRows` rows of the pass-through columns into 
on-heap vectors. */
+    private def copyColumns(

Review Comment:
   If any pass-through column of a batch cannot be held, this copies all the 
pass-through columns, including the retainable ones, row by row through 
`ColumnarBatchRow` and `RowToColumnConverter`. The allocation and conversion 
are also almost the same as in `rowsToColumnarBatches`. For a batch of 100 
columns where only one is a reused vector, all 100 columns are copied with 
per-row virtual calls for every batch. Would it be cheaper to keep the 
retainable columns as they are and copy only the others, or to copy column by 
column?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowBackedDataSourceV2.scala:
##########
@@ -250,6 +277,58 @@ private class ReusingArrowPartitionReader(start: Int, end: 
Int, batchSize: Int)
   }
 }
 
+/** Returns the batches of `first`, then those of `second`. */
+private class SwitchingArrowPartitionReader(
+    first: PartitionReader[ColumnarBatch],
+    second: PartitionReader[ColumnarBatch])
+  extends PartitionReader[ColumnarBatch] {
+  private var current = first
+
+  override def next(): Boolean = {
+    if (current.next()) return true
+    if (current eq second) return false
+    current = second
+    second.next()
+  }
+
+  override def get(): ColumnarBatch = current.get()
+
+  override def close(): Unit = {
+    first.close()
+    second.close()
+  }
+}
+
+/**
+ * Reads the batches of `fresh` and `reused` in step, which cover the same 
rows, and returns the
+ * `id` column of the first with the other columns of the second: a batch 
whose first column is
+ * retainable while the others are not.
+ */
+private class MixedArrowPartitionReader(
+    fresh: PartitionReader[ColumnarBatch],
+    reused: PartitionReader[ColumnarBatch])
+  extends PartitionReader[ColumnarBatch] {
+
+  override def next(): Boolean = {
+    val hasNext = fresh.next()
+    assert(reused.next() == hasNext)

Review Comment:
   nit: `reused.next()` has a side effect, but it is called inside 
`assert(...)`, which is elidable in Scala (e.g., with `-Xelide-below`). Then 
the reused reader would not advance, and the other columns would be empty or 
stale. Could we evaluate it first?
   
   ```scala
   val reusedHasNext = reused.next()
   assert(reusedHasNext == hasNext)
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -5708,15 +5708,16 @@ object SQLConf {
 
   val ARROW_PYSPARK_UDF_COLUMNAR_INPUT_ENABLED =
     buildConf("spark.sql.execution.arrow.pythonUDF.columnarInput.enabled")
-      .doc("When true, Arrow-based Python UDFs (pandas UDFs) can accept " +
-        "columnar input directly from upstream operators that produce " +
-        "Arrow-backed ColumnarBatch (e.g., DataSource V2 connectors), " +
-        "bypassing the ColumnarToRow and ArrowWriter conversion. " +
-        "This optimization reduces data transfer overhead between " +
-        "the JVM and Python worker processes. It keeps the columns that the 
UDFs pass " +
-        "through until the results of their batch come back from Python, so it 
requires " +
-        "an upstream operator whose batches stay valid until then, which a 
columnar " +
-        "reader does not promise.")
+      .doc("When true, Arrow-based Python UDFs (pandas UDFs) accept columnar 
input " +
+        "from upstream operators that produce ColumnarBatch, and write the UDF 
inputs " +
+        "of Arrow-backed batches to Python directly. The columns that the UDFs 
pass " +
+        "through skip the conversion to rows and back only if all the columns 
of the " +

Review Comment:
   The doc says that the pass-through columns skip the conversion to rows and 
back if all the columns of the first batch are retainable `ArrowColumnVector`s, 
but it also requires the UDF inputs to be plain column references; otherwise, 
it takes the row fallback path (Path 3). The migration guide entry has the same 
gap. For example, a DSv2 source that implements `isRetainable` would still go 
through rows with `udf(concat(name, '_'))`, which can be confusing when 
diagnosing performance. Could we mention this condition?



-- 
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