Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion python/pyspark/sql/tests/arrow/test_arrow_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ def func(iterator):

df = self.spark.range(10)
actual = df.repartition(1).mapInArrow(func, "a long").collect()
self.assertEqual(set((r.a for r in actual)), set(range(100)))
# Each input batch produces 0..99 once, in order, since there is one partition.
self.assertEqual([r.a for r in actual], list(range(100)) * max(1, len(actual) // 100))

def test_other_than_recordbatch_iter(self):
with self.quiet():
Expand Down
8 changes: 8 additions & 0 deletions python/pyspark/sql/tests/arrow/test_arrow_python_udf.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,14 @@ def create_struct_with_interval(interval_val, name_val):
self.assertIsNotNone(result["interval_field"])
self.assertEqual(result["name"], "test_name")

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


@unittest.skipIf(
not have_pandas or not have_pyarrow, pandas_requirement_message or pyarrow_requirement_message
Expand Down
16 changes: 16 additions & 0 deletions python/pyspark/sql/tests/arrow/test_arrow_python_udf_cached.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,22 @@ def test_udf_on_cached_numeric_column_alongside_unselected_string(self):
finally:
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.
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()

def test_udf_on_cached_map_column(self):
# Canonical map vectors from the cache pass the gate (their entry-struct children carry
# the canonical "key"/"value" names in order) and ride the columnar path verbatim. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,14 @@ def prepare_test_resource():
.start()
)

self.assertEqual(q.name, "this_query")
self.assertTrue(q.isActive)
q.processAllAvailable()
self.assertTrue(q.exception() is None)
try:
self.assertEqual(q.name, "this_query")
self.assertTrue(q.isActive)
q.processAllAvailable()
self.assertTrue(q.exception() is None)
finally:
q.stop()
shutil.rmtree(input_path, ignore_errors=True)

def test_apply_in_pandas_with_state_basic(self):
def func(key, pdf_iter, state):
Expand Down Expand Up @@ -194,6 +198,30 @@ def check_results(batch_df, _):

self._test_apply_in_pandas_with_state_basic(func, check_results)

def test_apply_in_pandas_with_state_output_batch_slicing(self):
# The worker packs the data rows (5 per group) and state rows (1 per group) of the groups
# in a partition into one output batch. Its data and state counts cover the whole batch,
# so it must not be sliced by the configs below.
def func(key, pdf_iter, state):
state.update((1,))
yield pd.DataFrame(
{"key": [f"{key[0]}_{i}" for i in range(5)], "countAsString": list("01234")}
)

expected = sorted(
Row(key=f"{k}_{i}", countAsString=str(i)) for k in ["hello", "this"] for i in range(5)
)

def check_results(batch_df, _):
assert sorted(batch_df.collect()) == expected

for conf in [
{"spark.sql.execution.arrow.maxRecordsPerOutputBatch": "3"},
{"spark.sql.execution.arrow.maxBytesPerOutputBatch": "3"},
]:
with self.subTest(conf=conf), self.sql_conf(conf):
self._test_apply_in_pandas_with_state_basic(func, check_results)

Copy link
Copy Markdown
Member

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 leaves this_query running 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-side foreachBatch worker. The cost is small, since at most one such query is alive at a time. Could we add a try/finally with q.stop() and shutil.rmtree(input_path, ignore_errors=True) to the helper, here or in a follow-up, so that it covers all its callers?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the try/finally to the helper in 9656977.


def test_apply_in_pandas_with_state_python_worker_random_failure(self):
input_path = tempfile.mkdtemp()
output_path = tempfile.mkdtemp()
Expand Down
21 changes: 21 additions & 0 deletions python/pyspark/sql/tests/test_udtf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3786,6 +3786,27 @@ def eval(self):
):
udtf(TestUDTF, returnType=ret_type)().collect()

def test_udtf_lateral_join_with_output_batch_slicing(self):
# Each output batch is joined with one input row, so it must not be sliced by the
# configs below.
@udtf(returnType="x: int")
class TestUDTF:
def eval(self, n: int):
for i in range(5):
yield (n * 10 + i,)

self.spark.udtf.register("test_udtf_slicing", TestUDTF)
expected = [Row(id=n, x=n * 10 + i) for n in range(3) for i in range(5)]
for conf in [
{"spark.sql.execution.arrow.maxRecordsPerOutputBatch": "3"},
{"spark.sql.execution.arrow.maxBytesPerOutputBatch": "3"},
]:
with self.subTest(conf=conf), self.sql_conf(conf):
df = self.spark.sql(
"SELECT t.id, u.x FROM range(0, 3, 1, 1) t, LATERAL test_udtf_slicing(t.id) u"
)
assertDataFrameEqual(df, expected)


class LegacyUDTFArrowTests(LegacyUDTFArrowTestsMixin, ReusedSQLTestCase):
@classmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 spark.sql.execution.arrow.pythonUDF.columnarInput.enabled nor ColumnarArrowPythonWithNamedArgumentRunner, which SPARK-56350 added in 4.2.0. A branch-4.1 backport would drop the ColumnarArrowPythonRunner.scala and test_arrow_python_udf_cached.py hunks, while both doc strings there are the same as at the merge base, so this hunk would apply cleanly and name an exception that branch-4.1 does not have. Could a branch-4.1 backport say "applyInPandasWithState and Arrow Python UDTFs ignore this config." in both docs? Also, branch-4.2 has neither test_arrow_python_udf_cached.py nor ArrowCachedBatchSerializer, so the columnar override would go there without a test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 checkValue below (L5478) bounds only the upper side, while PythonArrowOutput.scala:49 narrows the value with .toInt, so some negative values below Int.MinValue, which the doc above calls no limit, wrap to a positive limit. byteFromString (ConfigBuilder.scala:82-90) accepts a leading -, so -3g parses to -3221225472 and passes the check, and its low 32 bits are 1073741824, so PythonArrowOutput.scala:130 builds a SliceBytesArrowOutputProcessorImpl with a 1 GiB limit. -4294967295 becomes a 1-byte limit, which outputs every row as its own batch, and if maxRecordsPerOutputBatch is also set, PythonArrowOutput.scala:51-54 throws for the runners that keep the trait defaults, although the bytes limit is off. The same .toInt is on branch-4.1, branch-4.2 and branch-4.x. Could we clamp the value before narrowing, e.g. math.max(SQLConf.get.arrowMaxBytesPerOutputBatch, 0L).toInt at PythonArrowOutput.scala:49 (or bound the lower side here), together with SPARK-60395?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ class ArrowPythonUDTFRunner(
with BasicPythonArrowOutput {
ArrowUtils.failDuplicatedFieldNames(schema)

// `EvalPythonUDTFExec` joins each output batch with one input row, so the batches returned

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 (spark.python.udf.pipelined.enabled, new in 4.3.0, default false), BasePythonRunner.compute pulls the input iterator on a writer thread (PythonRunner.scala:464, :543, :1322), so count += 1 in projectedRowIter (EvalPythonUDTFExec.scala:104) runs on that thread, while if (count > 0) { queue.remove(); count -= 1 } (EvalPythonUDTFExec.scala:125-127) runs on the task thread. count is a captured local var, i.e. a plain scala.runtime.LongRef, and only the queue was made safe for this split (lockFree = pipelined, EvalPythonUDTFExec.scala:63-67). If the task reads c, the writer stores c + 1 and the task then stores c - 1, the increment is lost: when the task reaches the last queued input row, count is already 0, so that row's output is joined with the previous input row without any error. A lost decrement instead makes the first terminate() batch call queue.remove() on an exhausted queue. This is on master, branch-4.x and branch-4.3, not on branch-4.2, and the new LATERAL test runs in non-pipelined mode. Could we file a separate JIRA to make count an AtomicLong, or to split it into a writer-only @volatile counter and a task-only one?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 count shared. Filed SPARK-60414 for it, together with the passThruQueue race (item 16).

// from Python must not be sliced.
override protected def arrowMaxRecordsPerOutputBatch: Int = -1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the two defaults at PythonArrowOutput.scala:47-49 do not say when a runner has to override them. Once either internal config is set, every runner that mixes in the trait slices its output (PythonArrowOutput.scala:127-135), so these overrides are opt-outs, and none of the three runners that need one had it before this PR. ApplyInPandasWithStatePythonRunner (SPARK-40434, 3.4.0) and this runner (SPARK-43964, 3.5.0) predate the configs (SPARK-51769 and SPARK-51931, 4.1.0), and ColumnarArrowPythonWithNamedArgumentRunner (SPARK-56350, 4.2.0) was added after them, although its consumer asserts one result batch per input batch (ColumnarArrowEvalPythonEvaluatorFactory.scala:219-222). Could we add a short comment on the two defs, so that the next runner author sees the contract there? For example:

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 applyInPandasWithState tests. The repro of my item 3 still applies: with the Arrow cache serializer, columnar input enabled and maxRecordsPerOutputBatch=3, a scalar Arrow UDF on id of spark.range(0, 10, 1, 1).cache() takes evalArrowColumnar and fails at ColumnarArrowEvalPythonEvaluatorFactory.scala:220-222 with pass-through has 10 rows but UDF result has 3 rows (1 rows with maxBytesPerOutputBatch=3). ArrowPythonUDFCachedInputTests (test_arrow_python_udf_cached.py) already sets the static serializer, enables columnar input and has _assert_columnar_arrow_eval, but its 2-3-row inputs form 1-row batches, so the test needs one multi-row cached batch. Could we add a test like the following there and name it in the description? It also matters for backports, since columnar input is on by default on branch-4.3 and branch-4.2.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_udf_on_cached_input_with_output_batch_slicing as you suggested in eced9b0. Without this override it fails with pass-through has 10 rows but UDF result has 3 rows (1 rows with the bytes config). The description now names it.

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -225,34 +227,34 @@ abstract class BaseSliceArrowOutputProcessor(

override def produceBatch(): ColumnarBatch = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: pre-existing and outside this PR's diff (posted here since loadBatch at L216-224 has no diff line), but rowCount > 0 && at L217 never changes the result. currentRowIdx is -1 only while rowCount is -1 (L178, L212) and otherwise stays within [0, rowCount], so currentRowIdx < rowCount alone already implies rowCount >= 1. It gives the same answer for the initial state, an empty batch, a partly or fully output batch, and after the end of the stream. Could we drop the clause, e.g. with SPARK-60395?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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()
}
}
Expand All @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this is pre-existing, but getBatchBytes can still overflow for the struct-wrapped outputs of mapInArrow, mapInPandas, applyInPandas and UDTFs. Their root holds one top-level struct vector, and in Arrow 19 NonNullableStructVector.getBufferSize sums its children in a long and returns (int) buffer (StructVector then adds the validity size as an int), so the Long accumulator at L305-307 does not help. For example, a mapInArrow UDF that yields one batch of three int64 columns with 2^27 rows (about 3.06 GiB) measures -1,006,632,960 bytes, so with maxBytesPerOutputBatch=256m this check passes and the whole root goes out unsliced. The check at L284 can accept an oversized slice the same way, and ArrowStreamReader does accept record batch bodies over 2 GiB. Could we sum the sizes of the leaf vectors as Long in a follow-up?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Filed SPARK-60395 for this, together with the < vs. "equal or less" mismatch.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this is pre-existing (SPARK-51931), but the doc of spark.sql.execution.arrow.maxBytesPerOutputBatch says Spark will try to "create batches with the size equal or less than this value", while this check and L284 use < (the records processor uses <= at L322). So a batch whose size equals the limit exactly is split into about 90% and 10% chunks. The output never exceeds the limit, so this is only an inconsistency. Could we align the doc or the two checks in a follow-up?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed SPARK-60395 to align them, together with the getBatchBytes overflow for struct outputs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 getRoot "already sliced every chunk after the first", but before this PR it did not once produceBatch had closed the reader root. The closed vectors measure 0 bytes, so the old getBatchBytes(root) < arrowMaxBytesPerOutputBatch check returned the closed root itself as the next chunk, with currentRowIdx += root.getRowCount, since VectorSchemaRoot.close() keeps the row count; that is how the bytes slicing hit item 2 under "Why are the changes needed?". With the second fix, every chunk after the first is a slice anyway, so the guard is a no-op now. Could we say "SliceBytesArrowOutputProcessorImpl.getRoot gets a currentRowIdx == 0 guard; since 2 keeps the reader root open, every chunk after the first is a slice anyway, so this guard does not change the behavior."?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 (BaseVariableWidthVector.splitAndTransferOffsetBuffer in Arrow 19). So splitting one batch of R rows into chunks of c rows copies O(R^2/c) offsets instead of O(R). By my estimate, a 1M-row struct<long, string> batch (about 32 MiB) with maxBytesPerOutputBatch=1m takes about 824 probes for 33 outputs and copies about 567 MiB of offsets, versus about 4 MiB for a single pass. Also, the first probe, slice(0, rowCount), always fails because it measures the same as the root that L268 just checked, and the one-row fallback rebuilds the 1-row slice that the loop just closed. Could we start each search from the last row count that fit, e.g. in SPARK-60395, which touches this loop anyway?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, added it to SPARK-60395.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 return (L284) or with sliceBatchCount == 0, because (n * 0.9).toInt is in [0, n) for n >= 1, so if (sliceBatchCount == 0) at L292-294 is always true, and the initial root of var rootSlice (L274) is never read. Could we replace L292-295 with rootSlice = root.slice(currentRowIdx, 1), e.g. together with SPARK-60395?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it to SPARK-60395 as well.

Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 test_arrow_map.py (L416, L424) and the three new tests set them, and test_udf_on_cached_input_with_output_batch_slicing takes evalArrowColumnar, which now ignores them. PythonArrowOutputSuite already pins this getRoot, so this would only add end-to-end coverage of the row-by-row join at EvalPythonEvaluatorFactory.scala:124-125. Could we add something like the following to ArrowPythonUDFTestsMixin (test_arrow_python_udf.py), so that it also runs in the legacy, non-legacy and Connect classes? Without this fix it fails as in my item 7, with NoSuchElementException from HybridQueue.scala:165.

    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 maxBytesPerOutputBatch=3 case like in the other three tests would pass without this PR, since it cuts every 82-byte result batch down to one-row slices and never outputs the whole root.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_udf_with_output_batch_slicing to ArrowPythonUDFTestsMixin in 9656977. Without the getRoot fix it fails in HybridQueue with reader should not go beyond writer.


currentRowIdx = currentRowIdx + rootSlice.getRowCount
currentRowIdx = currentRowIdx + numRows

rootSlice
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: with this and the other two overrides, applyInPandasWithState, Arrow Python UDTFs and Arrow Python UDFs that take columnar input directly ignore both configs, but their docs (SQLConf.scala:5450-5455, SQLConf.scala:5463-5468) still say that they limit "a single ArrowRecordBatch to the downstream operator", "including the columnar-based operator implemented by third-party libraries". Both configs are internal, so these docs are their only spec, and setting both no longer throws for these three runners (PythonArrowOutput.scala:51-54) while it still does for the others. The worker-side workerOutputBatchMaxBytes already states its scope (SQLConf.scala:5653-5655). Could we add a sentence like "applyInPandasWithState, Arrow Python UDTFs and Arrow Python UDFs that take columnar input directly ignore this config." to both docs?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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, ArrowPythonUDTFRunner.scala:56-57, ColumnarArrowPythonRunner.scala:57-58, and the new sentence in both config docs), but the title names only the processor fixes. The title becomes the commit subject, so someone looking in git log for why these runners no longer slice would not find it there. Could we extend it, e.g. "[SPARK-60367][SQL] Fix sliced Arrow output processors and disable slicing for runners that need whole batches"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
Expand Down
Loading