sarutak commented on code in PR #59551:
URL: https://github.com/apache/spark/pull/59551#discussion_r4298230010
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -216,10 +235,52 @@ private[python] class
ColumnarArrowEvalPythonEvaluatorFactory(
assert(passThruRows == numRows,
s"Batch size mismatch: pass-through has " +
s"$passThruRows rows but UDF result has $numRows rows.")
+ // The previous output batch is no longer used once the next one is
asked for.
+ outputPassThruCols.foreach(_.close())
+ outputPassThruCols = passThruCols
new ColumnarBatch(passThruCols ++ resultCols, numRows)
}
}
+ /** Copies the pass-through columns of a batch into vectors allocated from
`allocator`. */
+ private def copyColumns(
+ batch: ColumnarBatch,
+ allocator: BufferAllocator): Array[ColumnVector] = {
+ val copies = ArrayBuffer.empty[ColumnVector]
+ try {
+ childOutput.indices.foreach { i =>
+ copies += copyColumn(batch.column(i), allocator)
+ }
+ copies.toArray
+ } catch {
+ case e: Throwable =>
+ copies.foreach(_.close())
+ throw e
+ }
+ }
+
+ private def copyColumn(column: ColumnVector, allocator: BufferAllocator):
ColumnVector = {
+ column match {
+ case arrow: ArrowColumnVector =>
+ val source = arrow.getValueVector
+ val copy = source.getField.createVector(allocator)
+ try {
+ copy.setInitialCapacity(source.getValueCount)
+ copy.allocateNew()
+ // Appends the values of the source to the empty copy, copying the
buffers.
+ source.accept(new VectorAppender(copy), null)
Review Comment:
`VectorAppender` does not support `ListViewVector`, which
`ArrowColumnVector` reads since SPARK-57929. With Arrow 19.0.0, I copied a
vector of each type that `ArrowColumnVector` accepts this way: all of them work
(including `ViewVarCharVector`, `ViewVarBinaryVector`, map, list and struct),
except `ListViewVector`, which fails with `UnsupportedOperationException:
VectorVisitor for ListViewVector is not supported.`
Since every pass-through column is copied, a query over a source that
returns a `ListViewVector` in any column now fails on this path, even when the
UDF does not read that column. Before this PR, the column was only referenced,
so it worked. Could the check at L157 also send such batches to the row queue
path (for example, by requiring that no column is a `ListViewVector`), and
could a test cover it?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -188,13 +194,26 @@ private[python] class
ColumnarArrowEvalPythonEvaluatorFactory(
outputTypes: Seq[DataType],
columnIndices: Array[Int]): Iterator[ColumnarBatch] = {
- val passThruQueue =
- new ArrayDeque[(Array[ColumnVector], Int)]()
+ // The pass-through columns of a batch are needed until its result comes
back from Python,
+ // which can be many batches later, but the producer may reuse or
release the vectors of a
+ // batch once it is asked for the next one, as ColumnarBatch allows. So
they are copied, and
+ // released once the output batch that holds them is no longer used:
when the next output
+ // batch is produced, or at the end of the task. In pipelined mode,
add() runs in the writer
+ // thread and poll() in the task thread.
+ val allocator = ArrowUtils.rootAllocator.newChildAllocator(
+ s"pass-through columns of Python UDFs in task
${context.taskAttemptId()}",
+ 0, Long.MaxValue)
+ val passThruQueue = new ConcurrentLinkedQueue[(Array[ColumnVector],
Int)]()
+ var outputPassThruCols = Array.empty[ColumnVector]
+ context.addTaskCompletionListener[Unit] { _ =>
Review Comment:
In pipelined mode, this listener can run while the writer thread is still in
`copyColumns` or before its `passThruQueue.add` at L216. Then the copy is not
closed, and `allocator.close()` fails with "Memory was leaked".
The root cause is not in this PR but in the listener of
`BasePythonRunner.createPipelinedDataIn`, which runs before this one. It calls
`writerFuture.cancel(true)` and then `writerFuture.get()`, but `get()` on a
cancelled `FutureTask` throws `CancellationException` right away, without
waiting for the thread. A small program confirmed this: `get()` returned while
the task was still running, and the task finished 500 ms later. So the comment
at `PythonRunner.scala` L545 ("Wait for the writer to actually exit ...") does
not hold. This PR adds a resource that the writer thread allocates and that a
listener frees.
To check it, I forced this order on 9c9a900 without changing its non-test
code, in pipelined mode with the `reuseVectors` source:
1. The task thread reads 10 rows and then stops reading
(`executedPlan.execute().mapPartitions(it => Iterator(it.take(10).size))`,
since `limit` is pushed below the UDF).
2. The writer thread, in `copyColumns` of the next batch, blocks without
being interruptible in `getValueVector` of the last column. I did this with a
test-only subclass of `ArrowColumnVector`, to stand in for the copy of a wide
column. At that point, the copies of the other three columns are allocated.
3. The task ends, and this listener runs while the writer is blocked there.
(A listener that the test reader registers, and that runs after this one,
confirms the order.)
All 10 runs failed with `TaskCompletionListenerException: Memory was leaked
by query. Memory leaked: (3072)` from `Allocator(pass-through columns of Python
UDFs in task ...)`. Also, the memory allocated in `ArrowUtils.rootAllocator`
grew by 3072 bytes with each run, and was still allocated 2 seconds after the
job ended, so the copies were not freed. Without forcing the order, I did not
see it in 100 runs on the narrow columns of the test source. A task stops early
this way when it is killed or fails, or when an operator above it stops reading.
Could the copy and `add` be guarded against the listener here, for example
with a lock and a `closed` flag that both check, so that a copy made after the
listener ran is closed right away? Fixing the runner so that its listener
really waits for the writer (for example, with a latch released at the end of
`PipelinedWriterRunnable.run`) would also cover this, and the existing cases
that the comment there describes, but that seems better as a separate JIRA.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -216,10 +235,52 @@ private[python] class
ColumnarArrowEvalPythonEvaluatorFactory(
assert(passThruRows == numRows,
s"Batch size mismatch: pass-through has " +
s"$passThruRows rows but UDF result has $numRows rows.")
+ // The previous output batch is no longer used once the next one is
asked for.
+ outputPassThruCols.foreach(_.close())
+ outputPassThruCols = passThruCols
new ColumnarBatch(passThruCols ++ resultCols, numRows)
}
}
+ /** Copies the pass-through columns of a batch into vectors allocated from
`allocator`. */
+ private def copyColumns(
+ batch: ColumnarBatch,
+ allocator: BufferAllocator): Array[ColumnVector] = {
+ val copies = ArrayBuffer.empty[ColumnVector]
+ try {
+ childOutput.indices.foreach { i =>
+ copies += copyColumn(batch.column(i), allocator)
+ }
+ copies.toArray
+ } catch {
+ case e: Throwable =>
+ copies.foreach(_.close())
+ throw e
+ }
+ }
+
+ private def copyColumn(column: ColumnVector, allocator: BufferAllocator):
ColumnVector = {
+ column match {
+ case arrow: ArrowColumnVector =>
+ val source = arrow.getValueVector
+ val copy = source.getField.createVector(allocator)
+ try {
+ copy.setInitialCapacity(source.getValueCount)
Review Comment:
The copy takes `source.getValueCount` rows, while the output batch has
`batch.numRows()` rows. Before this PR, a reader that set the number of rows of
the batch but not the value count of its vectors worked on this path, since
`ArrowColumnVector` does not read the value count and `writeArrowDirect` sets
it on the inputs of the UDF. Now the copy is shorter, and reading the missing
rows throws `IndexOutOfBoundsException` (I checked this with a value count of
0) or, within the allocated capacity, may read as nulls. The readers in Spark
set it, so this is minor, but a check that `source.getValueCount >=
batch.numRows()`, failing with a clear error, would make this case explicit.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -103,6 +109,36 @@ class ArrowColumnarPythonUDFSuite extends
SharedSparkSession {
}
}
+ test("Arrow-backed source that reuses its vectors: pass-through columns
match the results") {
+ assume(shouldTestPandasUDFs)
+ withSQLConf(SQLConf.ARROW_PYSPARK_EXECUTION_ENABLED.key -> "true") {
+ val pandasUDF = TestScalarPandasUDF(
+ name = "arrow_reuse_udf", returnType = Some(StringType))
+ registerTestUDF(pandasUDF, spark)
+
+ // Spark writes batches to Python before the results of the earlier ones
come back. With
+ // two batches, the reader is closed once it is exhausted, before their
results come back.
+ // With many, the reader refills its vectors with the next batches
before then.
+ Seq(200, 200000).foreach { numRows =>
+ // The test UDF casts its input to string: on a string column, the
cast is removed, so
+ // the input is a column, and the batches take the Arrow columnar path.
+ val result = readArrowSource(numRows = numRows, reuseVectors = true,
batchSize = 100)
+ .selectExpr("id", "name", "value", "data", "arrow_reuse_udf(name) as
udf_name")
+ val arrowExec =
collectNodes[ArrowEvalPythonExec](result.queryExecution.executedPlan)
+ assert(arrowExec.nonEmpty && arrowExec.head.child.supportsColumnar)
Review Comment:
Two suggestions for the test:
- `child.supportsColumnar` is also true for the other two paths. The test
takes the Arrow columnar path only because the cast of the test UDF is removed,
so if that changes, it would still pass without checking anything. Could it
also assert that all the inputs of the UDF are column references, for example
`arrowExec.head.udfs.flatMap(_.children).forall(_.isInstanceOf[AttributeReference])`?
- No test sets `spark.python.udf.pipelined.enabled=true`, so the change to
`ConcurrentLinkedQueue` is not covered, and no test has a source with both
Arrow and non-Arrow columns, which the new check at L157 is for. As the
pipelined mode is a `SparkConf`, the first one would need a suite that sets it
in `sparkConf`.
--
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]