Repository navigation
[SPARK-60306][SQL] Release consumed columnar batches in non-codegen ColumnarToRow and Arrow Python UDFs #59581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc2449a
dcdb133
7dc3bf8
2b49870
0dd2206
09bae80
a876069
dd1c0fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| /* | ||
| * 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()}. This covers the vector itself: the vectors that {@link #getChild(int)} | ||
| * returns for the fields of a struct are plain {@link ArrowColumnVector}s, which consumers do not | ||
| * release on their own. | ||
| */ | ||
| public class ReusedArrowColumnVector extends ArrowColumnVector { | ||
|
|
||
| public ReusedArrowColumnVector(ValueVector vector) { | ||
| super(vector); | ||
| } | ||
|
|
||
| @Override | ||
| public void closeIfFreeable() { | ||
| // no-op: the producer owns the vector and frees it in `close`. | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import org.apache.spark.sql.execution.vectorized.{OffHeapColumnVector, OnHeapCol | |
| import org.apache.spark.sql.types.StructType | ||
| import org.apache.spark.sql.vectorized.ColumnarBatch | ||
| import org.apache.spark.util.ArrayImplicits._ | ||
| import org.apache.spark.util.CompletionIterator | ||
|
|
||
| class ColumnarToRowEvaluatorFactory( | ||
| childOutput: Seq[Attribute], | ||
|
|
@@ -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]]( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. [Low] When The description says that a reused vector whose So a DSv2 source that refills one
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, you're right that this holds only where |
||
| input.rowIterator().asScala.map(toUnsafe), input.closeIfFreeable()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, added to the class doc in dd1c0fe: the consumer of the output releases the pass-through columns with |
||
| // batch once its rows are consumed. Otherwise it reads rows from its child, | ||
| // even from one that also supports columnar, such as the scan of the Arrow | ||
| // cache. Either way, the choice is fixed when the plan is built. | ||
| override def supportsColumnar: Boolean = | ||
| child.supportsColumnar && conf.arrowPySparkUDFColumnarInputEnabled | ||
| override def supportsRowBased: Boolean = true | ||
|
|
||
| override protected def doExecute(): RDD[InternalRow] = { | ||
| // Checks the configuration too, through `supportsColumnar`: a child that also returns rows, | ||
| // such as the scan of the Arrow cache, gets no ColumnarToRowExec when columnar input is | ||
| // disabled, and still supports columnar. | ||
| if (supportsColumnar) { | ||
| // Columnar path: delegate to doExecuteColumnar, flatten to | ||
| // UnsafeRow. ColumnarBatchRow from rowIterator() is NOT | ||
| // UnsafeRow, and downstream operators (e.g., outer | ||
| // EvalPythonExec) may cast to UnsafeRow. | ||
| doExecuteColumnar().mapPartitionsInternal { batchIter => | ||
| val toUnsafe = UnsafeProjection.create(schema) | ||
| batchIter.flatMap(_.rowIterator().asScala.map(toUnsafe)) | ||
| } | ||
| } else { | ||
| // Row-based path: unchanged. | ||
| super.doExecute() | ||
| } | ||
| } | ||
|
|
||
| override protected def doExecuteColumnar(): RDD[ColumnarBatch] = { | ||
| val inputRDD = child.executeColumnar() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,7 +50,12 @@ import org.apache.spark.util.Utils | |
| * reuse or release the vectors of a batch once it is asked for the next | ||
| * one. So `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled` | ||
| * is disabled by default. Output is produced by columnar combining: | ||
| * passThruCols ++ resultCols -> ColumnarBatch. | ||
| * passThruCols ++ resultCols -> ColumnarBatch, so the consumer of the | ||
| * output releases the pass-through columns with closeIfFreeable on the | ||
| * task thread. With `spark.python.udf.pipelined.enabled`, the Python | ||
| * writer reads the next input batches on its own thread meanwhile, so | ||
| * the upstream operator must allow its vectors to be released while it | ||
| * returns the next batches. | ||
| * | ||
| * 2. '''Non-Arrow columnar path''' (UDF inputs are simple column refs | ||
| * BUT columns are NOT [[ArrowColumnVector]]): Non-Arrow columnar | ||
|
|
@@ -63,6 +68,9 @@ import org.apache.spark.util.Utils | |
| * [[BasicPythonArrowInput]]. Uses [[HybridRowQueue]] for pass-through | ||
| * and converts joined rows back to [[ColumnarBatch]]. | ||
| * | ||
| * Paths 2 and 3 copy what they need from an input batch, so they release | ||
| * it with closeIfFreeable once it has been written to Python. | ||
| * | ||
| * TODO: Add a physical plan rule that inserts a ProjectExec before | ||
| * ArrowEvalPythonExec to pre-evaluate complex UDF input expressions | ||
| * into simple column references. This would eliminate path 3. | ||
|
|
@@ -255,12 +263,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 => | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. [Low] No test takes path 2, so this release is untested The new UDF test covers the Arrow columnar path (
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, added in dd1c0fe: the test source has an |
||
| batch.rowIterator().asScala.foreach { row => | ||
| queue.add(unsafeProj(row).copy()) | ||
| } | ||
| batch | ||
| } | ||
| true | ||
| }) | ||
| val pyRunner = | ||
| new ColumnarArrowPythonWithNamedArgumentRunner( | ||
| pyFuncs, evalType, argMetas, udfInputSchema, | ||
|
|
@@ -274,7 +282,9 @@ private[python] class ColumnarArrowEvalPythonEvaluatorFactory( | |
| val projection = MutableProjection.create( | ||
| allInputs, childOutput) | ||
| projection.initialize(context.partitionId()) | ||
| val batchIter = inputIter.map { batch => | ||
| // The rows of a batch are written to Python before the writer asks | ||
| // for the next batch, which releases it. | ||
| val batchIter = new WrittenBatchReleasingIterator(inputIter, _ => true).map { batch => | ||
| batch.rowIterator().asScala.map { row => | ||
| queue.add(unsafeProj(row).copy()) | ||
| projection(row) | ||
|
|
@@ -343,6 +353,41 @@ private[python] class ColumnarArrowEvalPythonEvaluatorFactory( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Hands the input batches to the Python writer. `enqueue` is called on each | ||
| * batch before the writer gets it, and returns whether the batch should be | ||
| * released with closeIfFreeable once it has been written: the writer is done | ||
| * with a batch when it asks for the next one or reaches the end. | ||
| */ | ||
| private[python] class WrittenBatchReleasingIterator( | ||
| input: Iterator[ColumnarBatch], | ||
| enqueue: ColumnarBatch => Boolean) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (P3): Both call sites always return Verification:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks. In this PR both callers release every batch, but #59582, which is stacked on this one, uses the |
||
| extends Iterator[ColumnarBatch] { | ||
|
|
||
| private var written: ColumnarBatch = _ | ||
|
|
||
| private def releaseWritten(): Unit = { | ||
| if (written != null) { | ||
| written.closeIfFreeable() | ||
| written = null | ||
| } | ||
| } | ||
|
|
||
| override def hasNext: Boolean = { | ||
| releaseWritten() | ||
| input.hasNext | ||
| } | ||
|
|
||
| override def next(): ColumnarBatch = { | ||
| releaseWritten() | ||
| val batch = input.next() | ||
| if (enqueue(batch)) { | ||
| written = batch | ||
| } | ||
| batch | ||
| } | ||
| } | ||
|
|
||
| /** Iterator wrapper that allows peeking at the first element. */ | ||
| private[python] class PeekableIterator[T](underlying: Iterator[T]) | ||
| extends Iterator[T] { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,10 +27,11 @@ import org.apache.arrow.vector.ipc.ArrowStreamReader | |
| import org.apache.spark.{SparkEnv, TaskContext} | ||
| import org.apache.spark.api.python.{BasePythonRunner, PythonWorker, PythonWorkerHandle, SpecialLengths} | ||
| import org.apache.spark.sql.execution.metric.SQLMetric | ||
| import org.apache.spark.sql.execution.vectorized.ReusedArrowColumnVector | ||
| import org.apache.spark.sql.internal.SQLConf | ||
| import org.apache.spark.sql.types.StructType | ||
| import org.apache.spark.sql.util.ArrowUtils | ||
| import org.apache.spark.sql.vectorized.{ArrowColumnVector, ColumnarBatch, ColumnVector} | ||
| import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector} | ||
|
|
||
| /** | ||
| * A trait that can be mixed-in with [[BasePythonRunner]]. It implements the logic from | ||
|
|
@@ -170,12 +171,18 @@ trait ArrowOutputProcessor { | |
| def close(): Unit | ||
| } | ||
|
|
||
| /** | ||
| * Loads every batch from Python into the reader's root and returns batches over the same vectors, | ||
| * so the vectors of a batch are only valid until the next one is loaded. They are | ||
| * [[ReusedArrowColumnVector]]s: a consumer does not hold them past the next batch, and its | ||
| * `closeIfFreeable` leaves them to this processor, which closes them in `close`. | ||
| */ | ||
| class ArrowOutputProcessorImpl(reader: ArrowStreamReader, pythonMetrics: Map[String, SQLMetric]) | ||
| extends ArrowOutputProcessor { | ||
| protected val root = reader.getVectorSchemaRoot() | ||
| protected val schema: StructType = ArrowUtils.fromArrowSchema(root.getSchema()) | ||
| private val vectors: Array[ColumnVector] = root.getFieldVectors().asScala.map { vector => | ||
| new ArrowColumnVector(vector) | ||
| new ReusedArrowColumnVector(vector) | ||
| }.toArray[ColumnVector] | ||
|
|
||
| protected var rowCount = -1 | ||
|
|
@@ -235,12 +242,15 @@ abstract class BaseSliceArrowOutputProcessor( | |
| batch | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-existing since SPARK-51769: the batch returned here repeats rows at the end of every Python batch that
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, this is fixed in #59616 (SPARK-60367), together with the closing of the reader's root from your comment on L252. |
||
| } | ||
|
|
||
| // The vectors are either those of the reader's root, into which the next batch is loaded, or | ||
| // those of a slice of it, which is closed once the next one is produced, so both are reused | ||
| // vectors. | ||
| protected override def getVectors(root: VectorSchemaRoot): Array[ColumnVector] = { | ||
| if (root eq this.root) { | ||
| super.getVectors(root) | ||
| } else { | ||
| root.getFieldVectors.asScala.map { vector => | ||
| new ArrowColumnVector(vector) | ||
| new ReusedArrowColumnVector(vector) | ||
| }.toArray[ColumnVector] | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
4. [Nit] Only the top-level vector is covered, not the child columns of a struct
getChildreturns the plainArrowColumnVectors thatArrowColumnVectorbuilds for the fields of a struct (ArrowColumnVector.java:163 and 257), socloseIfFreeableon 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'scloseIfFreeableleaves the vector to its producer. Could the Javadoc say that this covers the top-level vector, or couldgetChildreturn reused vectors too?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, I made the Javadoc say that it covers the vector itself, and that the vectors
getChildreturns for the fields of a struct are plainArrowColumnVectors, which consumers do not release on their own. Returning reused child vectors would need a change toArrowColumnVector, which builds them in package-private code.