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


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowBackedDataSourceV2.scala:
##########
@@ -269,24 +304,42 @@ private class ArrowBackedPartitionReader(start: Int, end: 
Int, batchSize: Int)
 
     // Wrap in ArrowColumnVector
     val columns: Array[ColumnVector] = Array(
-      new ArrowColumnVector(idVector),
+      new ReleaseCountingArrowColumnVector(idVector),
       new ArrowColumnVector(nameVector),
       new ArrowColumnVector(valueVector),
       new ArrowColumnVector(dataVector))
 
     currentBatch = new ColumnarBatch(columns, count)
-    allBatches.add(currentBatch)
+    allBatches.synchronized {
+      allBatches.add(currentBatch)
+      ArrowBackedDataSourceV2.returnedBatches.incrementAndGet()
+      ArrowBackedDataSourceV2.maxUnreleasedBatches.accumulateAndGet(
+        allBatches.size - numReleased, (a, b) => math.max(a, b))
+    }
     current += count
     true
   }
 
   override def get(): ColumnarBatch = currentBatch
 
+  // The consumer may still hold the returned batches, so they are closed when 
the task completes.
+  // The listener is registered here rather than in the constructor: the 
reader may be created by
+  // the writer thread of a pipelined Python UDF after the listener that waits 
for that writer to
+  // stop, and the listeners run in reverse order, so the batches could be 
freed while the writer

Review Comment:
   **5. [Nit] Since SPARK-60113, the order of the listeners no longer matters 
here**
   
   The listener that stops the pipelined writer is now added with 
`addTaskPreCompletionListener` (PythonRunner.scala:226). Such listeners run 
before every completion listener, from any thread and even one added later 
(TaskContext.scala:150-163), so "the listeners run in reverse order, so the 
batches could be freed while the writer still reads them" no longer holds: a 
listener added from the constructor, even on the writer thread, would also run 
only after the writer has stopped. SPARK-60113 (`ca1f5c5752e`) was already in 
this branch through `0dd22061215` when `09bae80321d` moved the registration 
here after [my earlier 
comment](https://github.com/apache/spark/pull/59581#discussion_r4318377947), 
which predates SPARK-60113. Could the comment give the current reason, or could 
the registration go back to the constructor, which would also make `closed` 
unnecessary?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -255,12 +258,12 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
 
       val resultIter = if (inputColumnIndices.isDefined) {
         // Path 2: simple column refs, non-Arrow vectors.
-        val bufferedIter = inputIter.map { batch =>
+        val bufferedIter = new WrittenBatchReleasingIterator(inputIter, { 
batch =>

Review Comment:
   **3. [Low] No test takes path 2, so this release is untested**
   
   The new UDF test covers the Arrow columnar path (`name`) and the row 
fallback path (`concat(name, '_')`), but not this one. 
`ArrowBackedPartitionReader` always puts an `ArrowColumnVector` first 
(ArrowBackedDataSourceV2.scala:306-310), so `isArrow` (L155-158) is true for 
every batch of the test source, and simple column references always take path 
1. If this iterator released a batch in `next()`, before the writer serializes 
it, or stopped releasing at all, every test would still pass. Could 
`ArrowBackedPartitionReader` get an option that wraps `id` in a 
release-counting `ColumnVector` that is not an `ArrowColumnVector`, delegating 
to the Arrow vector, so that `arrow_release_udf(name)` takes this path with the 
same value and counter assertions?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/ColumnarEvaluatorFactory.scala:
##########
@@ -44,10 +45,14 @@ class ColumnarToRowEvaluatorFactory(
         inputs: Iterator[ColumnarBatch]*): Iterator[InternalRow] = {
       assert(inputs.length == 1)
       val toUnsafe = UnsafeProjection.create(childOutput, childOutput)
+      // Releases each batch once all its rows have been returned, before the 
next batch is
+      // fetched, as the generated code of ColumnarToRowExec does in 
advanceBatch: flatMap checks
+      // the iterator of the current batch, which runs its completion, before 
the next batch.
       inputs.head.flatMap { input =>
         numInputBatches += 1
         numOutputRows += input.numRows()
-        input.rowIterator().asScala.map(toUnsafe)
+        CompletionIterator[InternalRow, Iterator[InternalRow]](

Review Comment:
   **1. [Low] When `ColumnarToRowExec` is not compiled, this now releases 
batches that nothing released before**
   
   The description says that a reused vector whose `closeIfFreeable` is not a 
no-op "is now closed by these consumers as well, as it already is by the 
generated code of `ColumnarToRowExec` since SPARK-50235", and [your 
reply](https://github.com/apache/spark/pull/59581#issuecomment-6276911959) says 
that such a source "is broken today regardless of this path". That holds only 
where `ColumnarToRowExec` is compiled. `CollapseCodegenStages` leaves it out 
when its input or output has more than `spark.sql.codegen.maxFields` (100) 
fields, nested ones included (WholeStageCodegenExec.scala:1214-1218), and a 
stage that fails to compile or exceeds `spark.sql.codegen.hugeMethodLimit` 
falls back to `child.execute()` (WholeStageCodegenExec.scala:1034 and 1046). In 
those cases, and with `spark.sql.codegen.wholeStage=false`, `doExecute` 
(Columnar.scala:84-97) runs this evaluator, which released nothing so far.
   
   So a DSv2 source that refills one `VectorSchemaRoot` per batch and wraps it 
in plain `ArrowColumnVector`s works today on a table with more than 100 fields, 
and fails after this change once it has a struct column. `closeIfFreeable` 
closes the struct vector, which drops its children, so the next 
`VectorLoader.load` into that root fails with `should have as many children as 
in the schema`, as `ArrowOutputProcessorSuite` shows for the Python output. The 
wrapper's `childColumns` is null by then as well 
(ArrowColumnVector.java:63-69). Could the user-facing section say that this now 
also applies where `ColumnarToRowExec` is not compiled, e.g. for more than 
`spark.sql.codegen.maxFields` fields? A line in the SQL migration guide may fit 
too.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/vectorized/ReusedArrowColumnVector.java:
##########
@@ -0,0 +1,43 @@
+/*
+ * 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.vectorized;
+
+import org.apache.arrow.vector.ValueVector;
+
+import org.apache.spark.sql.vectorized.ArrowColumnVector;
+
+/**
+ * An {@link ArrowColumnVector} whose producer may release or overwrite it 
after returning it, for
+ * example by loading every batch into the same Arrow vectors. A consumer must 
not hold it past the
+ * next batch.
+ *
+ * <p>Like the vectors that {@link WritableColumnVector} and {@link 
ConstantColumnVector} reuse
+ * across batches, the producer releases it: {@link #closeIfFreeable()}, which 
a consumer calls
+ * once it is done with a batch, is a no-op, and the producer frees the vector 
with
+ * {@link #close()}.
+ */
+public class ReusedArrowColumnVector extends ArrowColumnVector {
+
+  public ReusedArrowColumnVector(ValueVector vector) {
+    super(vector);
+  }
+
+  @Override
+  public void closeIfFreeable() {

Review Comment:
   **4. [Nit] Only the top-level vector is covered, not the child columns of a 
struct**
   
   `getChild` returns the plain `ArrowColumnVector`s that `ArrowColumnVector` 
builds for the fields of a struct (ArrowColumnVector.java:163 and 257), so 
`closeIfFreeable` on one of them still closes a vector of the producer's root. 
No consumer in Spark releases a child column on its own today, so this is only 
about the Javadoc above, which says that a consumer's `closeIfFreeable` leaves 
the vector to its producer. Could the Javadoc say that this covers the 
top-level vector, or could `getChild` return reused vectors too?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala:
##########
@@ -117,28 +117,13 @@ case class ArrowEvalPythonExec(
   // accept columnar input to avoid the ColumnarToRow -> ArrowWriter 
round-trip.
   // The Arrow FieldVectors are extracted directly from ArrowColumnVector and
   // serialized to IPC, bypassing the row-based ArrowWriter conversion.
+  // This node then outputs only columnar batches, so the planner inserts a
+  // ColumnarToRowExec above it for a row-based parent, which releases each

Review Comment:
   **2. [Low] In pipelined mode, the Arrow columnar path now releases its input 
batches on another thread than the one reading them**
   
   On this path, each output batch carries the columns of its input batch 
(ColumnarArrowEvalPythonEvaluatorFactory.scala:226), so the `ColumnarToRowExec` 
described here calls `closeIfFreeable` on them from the task thread. With 
`spark.python.udf.pipelined.enabled=true`, the writer pulls the upstream 
iterator on its own pool thread (PythonRunner.scala:1577 and 1594), so 
`PartitionReader.next()` for the later batches (DataSourceRDD.scala:240) runs 
on that thread at the same time. Before this PR nothing released these batches, 
and `advanceBatch`, the non-codegen evaluator and the row queue paths all 
release on the thread that reads. `ArrowBackedPartitionReader` synchronizes 
`allBatches` (ArrowBackedDataSourceV2.scala:285 and 313) because its 
`closeIfFreeable` and `next()` can now run on these two threads.
   
   Arrow allocators are thread-safe, but a source whose vectors return their 
buffers to a pool of the reader, or update other reader state when closed, now 
has to be thread-safe as well. Columnar input and pipelined mode are both off 
by default, so documenting it may be enough. Could the class doc of 
`ColumnarArrowEvalPythonEvaluatorFactory` (L45-53), which already says that the 
batches must stay valid until their results come back, also say that they are 
then released from the task thread while the writer may be reading the next 
ones?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -114,6 +115,103 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     }
   }
 
+  test("ColumnarToRow releases each batch once its rows are consumed") {
+    Seq(true, false).foreach { codegen =>
+      withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString) {
+        val numRows = 2000
+        val numBatches = 20
+        val df = readArrowSource(numRows = numRows, batchSize = numRows / 
numBatches)
+        val plan = df.queryExecution.executedPlan
+        assert(collectNodes[ColumnarToRowExec](plan).nonEmpty)
+        assert(collectNodes[WholeStageCodegenExec](plan).nonEmpty == codegen)
+
+        ArrowBackedDataSourceV2.resetBatchCounters()
+        val rows = df.collect()
+        assert(rows.map(_.getInt(0)).toSeq == (0 until numRows))
+        assert(rows.forall(row => row.getString(1) == s"row_${row.getInt(0)}"))
+        // A batch is released before the next one is read.
+        assert(ArrowBackedDataSourceV2.returnedBatches.get == numBatches)
+        assert(ArrowBackedDataSourceV2.releasedBatches.get == numBatches)
+        assert(ArrowBackedDataSourceV2.maxUnreleasedBatches.get == 1)
+      }
+    }
+  }
+
+  test("Arrow Python UDF over many batches releases each input batch") {
+    assume(shouldTestPandasUDFs)
+    val pandasUDF = TestScalarPandasUDF(
+      name = "arrow_release_udf", returnType = Some(StringType))
+    registerTestUDF(pandasUDF, spark)
+
+    val numRows = 20000
+    val numBatches = 20
+    // On a string column, the cast of the test UDF is removed, so the batches 
take the Arrow
+    // columnar path, whose output batches carry the input columns to the 
ColumnarToRowExec above
+    // ArrowEvalPythonExec, which releases them. On an expression, they take 
the row fallback
+    // path, which releases each batch once it has been written to Python.
+    for {
+      codegen <- Seq(true, false)
+      (input, suffix) <- Seq("name" -> "", "concat(name, '_')" -> "_")
+    } withSQLConf(
+        SQLConf.ARROW_PYSPARK_EXECUTION_ENABLED.key -> "true",
+        SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString) {
+        val result = readArrowSource(numRows = numRows, batchSize = numRows / 
numBatches)
+          .selectExpr("id", "name", "value", "data", 
s"arrow_release_udf($input) as udf_out")
+        val plan = result.queryExecution.executedPlan
+        val arrowExec = collectNodes[ArrowEvalPythonExec](plan)
+        assert(arrowExec.nonEmpty && arrowExec.head.supportsColumnar)
+        
assert(arrowExec.head.udfs.forall(_.children.forall(_.isInstanceOf[AttributeReference]))
+          == suffix.isEmpty, s"Unexpected UDF inputs:\n$plan")
+        assert(collectNodes[ColumnarToRowExec](plan).exists(
+          _.find(_.isInstanceOf[ArrowEvalPythonExec]).isDefined), s"No 
ColumnarToRowExec:\n$plan")
+
+        ArrowBackedDataSourceV2.resetBatchCounters()
+        val rows = result.collect()
+        assert(rows.map(_.getInt(0)).toSeq == (0 until numRows))
+        rows.foreach { row =>
+          val id = row.getInt(0)
+          assert(row.getString(1) == s"row_$id", s"name mismatch for id $id")
+          assert(row.getDouble(2) == id * 0.1, s"value mismatch for id $id")
+          assert(row.getString(3).length == 100, s"data mismatch for id $id")
+          assert(row.getString(4) == s"row_$id$suffix", s"UDF result mismatch 
for id $id")
+        }
+        // Every batch is released before the task completes. The row fallback 
path releases a
+        // batch before it reads the next one, while the Arrow columnar path 
holds the batches
+        // that it has written to Python until their results are consumed.
+        assert(ArrowBackedDataSourceV2.returnedBatches.get == numBatches)
+        assert(ArrowBackedDataSourceV2.releasedBatches.get == numBatches)
+        if (suffix.nonEmpty) {
+          assert(ArrowBackedDataSourceV2.maxUnreleasedBatches.get == 1)
+        }
+
+        // A limit stops consuming the output batches early, and the generated 
code of
+        // ColumnarToRowExec closes the current one.
+        assert(result.limit(10).selectExpr("count(*)").head().getLong(0) == 10)
+      }
+  }
+
+  test("A plan built with columnar input runs as built after the configuration 
changes") {

Review Comment:
   **6. [Nit] Could the new tests carry the JIRA ID?**
   
   This test reproduces the `ClassCastException` that this PR fixes, so it 
could be named `SPARK-60306: A plan built with columnar input runs as built 
after the configuration changes`, as Spark tests for a JIRA issue usually are. 
The same applies to the tests at L118 and L140, and to the one in 
`ArrowOutputProcessorSuite`.



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