Repository navigation
[SPARK-60367][SQL] Fix sliced Arrow output processors and disable slicing for runners that need whole batches #59616
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
base: master
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -5452,7 +5452,9 @@ object SQLConf { | |
| "there is no limit. Note that the complete ArrowRecordBatch is actually created but " + | ||
| "the number of records is limited when sending it to the downstream operator. This is " + | ||
| "used to avoid large batches being sent to the downstream operator including " + | ||
| "the columnar-based operator implemented by third-party libraries.") | ||
| "the columnar-based operator implemented by third-party libraries. " + | ||
| "applyInPandasWithState, Arrow Python UDTFs and Arrow Python UDFs that take " + | ||
|
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 (backport): the sentence I suggested in item 20 is accurate on master, branch-4.x and branch-4.3, but branch-4.1 has no columnar-input path: neither
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. Good point. A branch-4.1 backport will use that sentence, and a branch-4.2 backport will note that the columnar override has no test there. |
||
| "columnar input directly ignore this config.") | ||
| .version("4.1.0") | ||
| .internal() | ||
| .intConf | ||
|
|
@@ -5468,7 +5470,8 @@ object SQLConf { | |
| "the columnar-based operator implemented by third-party libraries. Spark will try to " + | ||
| "create batches with the size equal or less than this value. Normally it should not " + | ||
| "happen, but if in extreme case that even one record is still larger than this value, " + | ||
| "Spark will create a batch with one record.") | ||
| "Spark will create a batch with one record. applyInPandasWithState, Arrow Python " + | ||
| "UDTFs and Arrow Python UDFs that take columnar input directly ignore this config.") | ||
| .version("4.1.0") | ||
| .internal() | ||
| .bytesConf(ByteUnit.BYTE) | ||
|
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. Minor: this is pre-existing (SPARK-51931) and outside this PR's diff, but the
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. Good catch. Added it to SPARK-60395, which already covers the bytes config. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,11 @@ class ArrowPythonUDTFRunner( | |
| with BasicPythonArrowOutput { | ||
| ArrowUtils.failDuplicatedFieldNames(schema) | ||
|
|
||
| // `EvalPythonUDTFExec` joins each output batch with one input row, so the batches returned | ||
|
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. Minor: this is pre-existing and outside this PR's diff, but it breaks the pairing that this comment relies on. In pipelined mode (
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. Good catch. SPARK-56642 made the queue lock-free for the writer/task split but left |
||
| // from Python must not be sliced. | ||
| override protected def arrowMaxRecordsPerOutputBatch: Int = -1 | ||
|
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: the two defaults at // The limits for slicing the batches returned from Python. A runner whose consumer cannot take
// a batch split into several, e.g. one that pairs each batch with one input row, overrides both
// to -1, as `ArrowPythonUDTFRunner` does.
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. Added the comment on the two defs in 9656977. |
||
| override protected def arrowMaxBytesPerOutputBatch: Int = -1 | ||
|
|
||
| override protected def runnerConf: Map[String, String] = super.runnerConf ++ pythonRunnerConf | ||
|
|
||
| override protected def evalConf: Map[String, String] = { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,6 +52,11 @@ private[python] class ColumnarArrowPythonWithNamedArgumentRunner( | |
| with ColumnarArrowPythonInput | ||
| with BasicPythonArrowOutput { | ||
|
|
||
| // `ColumnarArrowEvalPythonEvaluatorFactory.evalArrowColumnar` pairs each output batch with | ||
| // one input batch, so the batches returned from Python must not be sliced. | ||
| override protected def arrowMaxRecordsPerOutputBatch: Int = -1 | ||
|
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. This override is the only one of the three without a test that fails without it. Reverting L57-58 keeps every test green, since nothing runs the columnar path under either config, and the testing section of the description names only the UDTF and 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.
df = self.spark.range(0, 10, 1, 1).cache()
try:
for conf in [
{"spark.sql.execution.arrow.maxRecordsPerOutputBatch": "3"},
{"spark.sql.execution.arrow.maxBytesPerOutputBatch": "3"},
]:
with self.subTest(conf=conf), self.sql_conf(conf):
result = df.select(col("id"), udf(lambda x: x + 1, "long")(col("id")))
self._assert_columnar_arrow_eval(result, expected_nodes=1)
assertDataFrameEqual(result, [Row(i, i + 1) for i in range(10)])
finally:
df.unpersist()
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. Added |
||
| override protected def arrowMaxBytesPerOutputBatch: Int = -1 | ||
|
|
||
| override protected def runnerConf: Map[String, String] = super.runnerConf ++ pythonRunnerConf | ||
|
|
||
| // The input schema (and per-UDF nesting depth) travel in evalConf, exactly like | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,9 @@ private[python] trait PythonArrowOutput[OUT <: AnyRef] { self: BasePythonRunner[ | |
|
|
||
| protected def deserializeColumnarBatch(batch: ColumnarBatch, schema: StructType): OUT | ||
|
|
||
| // The limits for slicing the batches returned from Python. A runner whose consumer cannot take | ||
| // a batch split into several, e.g. one that pairs each batch with one input row, overrides both | ||
| // to -1, as `ArrowPythonUDTFRunner` does. | ||
| protected def arrowMaxRecordsPerOutputBatch: Int = SQLConf.get.arrowMaxRecordsPerOutputBatch | ||
|
|
||
| protected def arrowMaxBytesPerOutputBatch: Int = SQLConf.get.arrowMaxBytesPerOutputBatch.toInt | ||
|
|
@@ -210,8 +213,7 @@ abstract class BaseSliceArrowOutputProcessor( | |
| extends ArrowOutputProcessorImpl(reader, pythonMetrics) { | ||
|
|
||
| protected var currentRowIdx = -1 | ||
| protected var prevRoot: VectorSchemaRoot = null | ||
| protected var prevVectors: Array[ColumnVector] = _ | ||
| protected var prevRoot: VectorSchemaRoot = root | ||
|
|
||
| override def loadBatch(): Boolean = { | ||
| if (rowCount > 0 && currentRowIdx < rowCount) { | ||
|
|
@@ -225,34 +227,34 @@ abstract class BaseSliceArrowOutputProcessor( | |
|
|
||
| override def produceBatch(): ColumnarBatch = { | ||
|
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: pre-existing and outside this PR's diff (posted here since
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. Added it to SPARK-60395. |
||
| val batchRoot = getRoot | ||
| closePrevSlice() | ||
| prevRoot = batchRoot | ||
|
|
||
| if (batchRoot != prevRoot) { | ||
| if (prevRoot != null) { | ||
| prevVectors.foreach(_.close()) | ||
| prevRoot.close() | ||
| } | ||
| prevRoot = batchRoot | ||
| } | ||
|
|
||
| val vectors = getVectors(batchRoot) | ||
| prevVectors = vectors | ||
|
|
||
| val batch = new ColumnarBatch(vectors) | ||
| val batch = new ColumnarBatch(getVectors(batchRoot)) | ||
| batch.setNumRows(batchRoot.getRowCount) | ||
| batch | ||
| } | ||
|
|
||
| protected override def getVectors(root: VectorSchemaRoot): Array[ColumnVector] = { | ||
| root.getFieldVectors.asScala.map { vector => | ||
| new ArrowColumnVector(vector) | ||
| }.toArray[ColumnVector] | ||
| if (root eq this.root) { | ||
| super.getVectors(root) | ||
| } else { | ||
| root.getFieldVectors.asScala.map { vector => | ||
| new ArrowColumnVector(vector) | ||
| }.toArray[ColumnVector] | ||
| } | ||
| } | ||
|
|
||
| override def close(): Unit = { | ||
| if (prevRoot != null) { | ||
| prevVectors.foreach(_.close()) | ||
| // Releases the previous output batch if it is a slice. The reader loads every batch from Python | ||
| // into the same root, so it must not be closed here; `ArrowOutputProcessorImpl.close` closes it. | ||
| private def closePrevSlice(): Unit = { | ||
| if (prevRoot ne root) { | ||
| prevRoot.close() | ||
| } | ||
| } | ||
|
|
||
| override def close(): Unit = { | ||
| closePrevSlice() | ||
| super.close() | ||
| } | ||
| } | ||
|
|
@@ -264,7 +266,8 @@ class SliceBytesArrowOutputProcessorImpl( | |
| extends BaseSliceArrowOutputProcessor(reader, pythonMetrics) { | ||
|
|
||
| protected override def getRoot: VectorSchemaRoot = { | ||
| if (getBatchBytes(root) < arrowMaxBytesPerOutputBatch) { | ||
| // Output the whole root only if no row of this batch has been output yet. | ||
| if (currentRowIdx == 0 && getBatchBytes(root) < arrowMaxBytesPerOutputBatch) { | ||
|
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. Minor: this is pre-existing, but
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. Good catch. Filed SPARK-60395 for this, together with the
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: this is pre-existing (SPARK-51931), but the 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. Filed SPARK-60395 to align them, together with the
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 (PR description): item 1 of the description says that this guard does not change the behavior because the bytes
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. Right, thanks. Updated item 1 of the description as you suggested. |
||
| currentRowIdx = currentRowIdx + root.getRowCount | ||
| root | ||
| } else { | ||
|
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. Minor: this is pre-existing (SPARK-51931), but the search below restarts from all remaining rows for every output batch (L276), and each probe that starts at a non-zero row allocates and refills its offset buffers (
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. Agreed, added it to SPARK-60395.
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: also pre-existing, in the same branch. The loop below ends only through
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. Added it to SPARK-60395 as well. |
||
|
|
@@ -313,14 +316,11 @@ class SliceRecordsArrowOutputProcessorImpl( | |
| extends BaseSliceArrowOutputProcessor(reader, pythonMetrics) { | ||
|
|
||
| protected override def getRoot: VectorSchemaRoot = { | ||
| val remainingRows = rowCount - currentRowIdx | ||
| val rootSlice = if (remainingRows > arrowMaxRecordsPerOutputBatch) { | ||
| root.slice(currentRowIdx, arrowMaxRecordsPerOutputBatch) | ||
| } else { | ||
| root | ||
| } | ||
| val numRows = math.min(rowCount - currentRowIdx, arrowMaxRecordsPerOutputBatch) | ||
| // Output the whole root only if this chunk is the whole batch. | ||
| val rootSlice = if (numRows == rowCount) root else root.slice(currentRowIdx, numRows) | ||
|
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 (optional): the first user-facing bullet of the description says that Arrow scalar Python UDFs no longer return wrong values or fail, but no test runs one on the row path under either config. Only def test_udf_with_output_batch_slicing(self):
# Each result row is joined with the next queued input row, so the sliced 10-row result
# batch must be output once and in order.
df = self.spark.range(0, 10, 1, 1)
with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerOutputBatch": "3"}):
result = df.select(col("id"), udf(lambda x: x + 1, "long")(col("id")))
assertDataFrameEqual(result, [Row(i, i + 1) for i in range(10)])A
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. Added |
||
|
|
||
| currentRowIdx = currentRowIdx + rootSlice.getRowCount | ||
| currentRowIdx = currentRowIdx + numRows | ||
|
|
||
| rootSlice | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,11 @@ class ApplyInPandasWithStatePythonRunner( | |
| with PythonArrowOutput[OutType] { | ||
| ArrowUtils.failDuplicatedFieldNames(inputSchema) | ||
|
|
||
| // The data and state counts of each batch returned from Python cover the whole batch and are | ||
| // read from its first row, so the batch must not be sliced. | ||
| override protected def arrowMaxRecordsPerOutputBatch: Int = -1 | ||
|
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. Minor: with this and the other two overrides,
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. Added the sentence to both docs in eced9b0. |
||
| override protected def arrowMaxBytesPerOutputBatch: Int = -1 | ||
|
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 (PR title): since c0490ad, this PR also makes three runners ignore both configs (these overrides,
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. Updated the title as you suggested. |
||
|
|
||
| // Installed at worker launch. This runner is constructed per task, so each launch reads the | ||
| // configuration that task carries. A running streaming query carries a snapshot: | ||
| // `StreamExecution` runs its batches on `sparkSession.cloneSession()`, whose conf is copied at | ||
|
|
||
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.
Nit: pre-existing and not changed here, but
_test_apply_in_pandas_with_state_basic(L62-99) neither stops its query nor removes its input directory, so each call leavesthis_queryrunning until the next call stops it (L79-80). This test now does that twice, and in the Connect parity class each live query also keeps a server-sideforeachBatchworker. The cost is small, since at most one such query is alive at a time. Could we add atry/finallywithq.stop()andshutil.rmtree(input_path, ignore_errors=True)to the helper, here or in a follow-up, so that it covers all its callers?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.
Added the
try/finallyto the helper in 9656977.