This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 9a639fddfc93 [SPARK-57593][SQL][PYTHON] Byte-bound and instrument the
pickle Python UDF input batch
9a639fddfc93 is described below
commit 9a639fddfc9363d803005ba9f99ba1d023a806d8
Author: Tengfei Huang <[email protected]>
AuthorDate: Tue Jun 23 08:44:20 2026 -0700
[SPARK-57593][SQL][PYTHON] Byte-bound and instrument the pickle Python UDF
input batch
### What changes were proposed in this pull request?
Regular batched Python UDFs (`BatchEvalPythonExec`) pickle each batch of
rows into a single contiguous on-heap byte array, and batches are formed by
**row count only** (`spark.sql.execution.python.udf.maxRecordsPerBatch`,
default 100). With wide rows, a single pickled batch can reach hundreds of MB
to over 1 GB of contiguous JVM heap and OOM the executor.
This PR adds an optional byte-size bound on these batches, plus
observability to size and verify the bound. All new behavior is off by default;
the default path is byte-for-byte unchanged.
1. **Byte cap** — new config
`spark.sql.execution.python.udf.maxBytesPerBatch` (internal, default `-1` =
off). When set to a finite value, a batch is cut at the **min** of the
row-count and byte limits. The per-row size is a best-effort estimate of the
*pickled* size of the converted row, accounted by `EvaluatePython.toJava` at
its leaf cases **during conversion itself** (no second traversal):
`UTF8String.numBytes` is the exact UTF-8 byte count pickle writes, decimals are
sized by pre [...]
2. **Observability** — new `SQLMetric`s on `BatchEvalPythonExec`:
- `pythonPeakPickledBatchBytes` — peak per-batch pickled size (size
metric; cross-task peak shown in the UI's min/med/max breakdown, like
`peakMemory`). Always recorded.
- `pythonOversizedBatchCount` — number of batches cut at the byte limit
(only populated when a finite cap is set).
- `pythonEstimatedInputBytes` — sum of the per-row size estimates, to
compare against the measured `pythonDataSent` for estimator-accuracy analysis
(only populated when a cap is set).
The Python UDTF path (`BatchEvalPythonUDTFExec`) calls `getInputIterator`
with no cap and is unaffected, only metrics added.
### Why are the changes needed?
Batching by row count alone gives no bound on the per-batch heap footprint:
with wide rows (large strings/binary/decimal payloads), one pickled batch
becomes a very large contiguous allocation that can OOM the executor even when
the row *count* is small. The byte cap provides a guardrail against that
allocation, and the metrics make peak batch pressure observable so a safe
threshold can be chosen and the estimator's accuracy verified before enforcing.
### Does this PR introduce _any_ user-facing change?
No
### How was this patch tested?
New unit tests in `BatchEvalPythonExecSuite`
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
Closes #56649 from ivoson/SPARK-57593.
Authored-by: Tengfei Huang <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../org/apache/spark/sql/internal/SQLConf.scala | 19 ++
.../sql/execution/python/BatchEvalPythonExec.scala | 73 ++++++-
.../execution/python/BatchEvalPythonUDTFExec.scala | 9 +-
.../python/ByteBoundedAsArrayIterator.scala | 128 +++++++++++++
.../sql/execution/python/EvaluatePython.scala | 52 +++--
.../sql/execution/python/PythonSQLMetrics.scala | 49 ++++-
.../python/BatchEvalPythonExecSuite.scala | 211 ++++++++++++++++++++-
.../sql/execution/python/PythonUDFSuite.scala | 32 ++++
8 files changed, 546 insertions(+), 27 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index db74d0378fc1..05043b3107bf 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -4777,6 +4777,25 @@ object SQLConf {
"must be positive.")
.createWithDefault(100)
+ val PYTHON_UDF_MAX_BYTES_PER_BATCH =
+ buildConf("spark.sql.execution.python.udf.maxBytesPerBatch")
+ .internal()
+ .doc("Byte-size cap for a batch of rows sent to a worker for regular
(pickle-serialized, " +
+ "non-Arrow) Python UDF evaluation. Without it, BatchEvalPythonExec
batches purely by " +
+ "row count (spark.sql.execution.python.udf.maxRecordsPerBatch), so one
oversized batch " +
+ "can OOM the executor; a finite value caps the batch at the min of the
row-count and " +
+ "byte limits. The size is a best-effort per-row estimate of the
pickled size of the " +
+ "converted row (its accuracy is observable via the
pythonEstimatedInputBytes vs " +
+ "pythonDataSent metrics); a row larger than the cap still yields a
one-row batch. " +
+ "-1 (the default) means no limit.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+ .bytesConf(ByteUnit.BYTE)
+ .checkValue(x => x == -1 || (x > 0 && x <= Int.MaxValue),
+ "The value of spark.sql.execution.python.udf.maxBytesPerBatch should "
+
+ "be -1 (no limit) or greater than zero and less than or equal to
INT_MAX.")
+ .createWithDefault(-1)
+
val PYTHON_UDF_BUFFER_SIZE =
buildConf("spark.sql.execution.python.udf.buffer.size")
.doc(
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExec.scala
index a42c8ac8da53..4c39ce98cf55 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExec.scala
@@ -17,6 +17,8 @@
package org.apache.spark.sql.execution.python
+import java.io.ByteArrayOutputStream
+
import scala.jdk.CollectionConverters._
import net.razorvine.pickle.{Pickler, Unpickler}
@@ -35,7 +37,7 @@ import org.apache.spark.sql.types.{StructField, StructType}
* A physical plan that evaluates a [[PythonUDF]]
*/
case class BatchEvalPythonExec(udfs: Seq[PythonUDF], resultAttrs:
Seq[Attribute], child: SparkPlan)
- extends EvalPythonExec with PythonSQLMetrics {
+ extends EvalPythonExec with PythonPickleBatchMetrics {
private[this] val jobArtifactUUID =
JobArtifactSet.getCurrentJobArtifactState.map(_.uuid)
private[this] val sessionUUID = {
@@ -47,12 +49,14 @@ case class BatchEvalPythonExec(udfs: Seq[PythonUDF],
resultAttrs: Seq[Attribute]
override protected def evaluatorFactory: EvalPythonEvaluatorFactory = {
val batchSize = conf.getConf(SQLConf.PYTHON_UDF_MAX_RECORDS_PER_BATCH)
+ val maxBytesPerBatch = conf.getConf(SQLConf.PYTHON_UDF_MAX_BYTES_PER_BATCH)
val binaryAsBytes = conf.pysparkBinaryAsBytes
new BatchEvalPythonEvaluatorFactory(
child.output,
udfs,
output,
batchSize,
+ maxBytesPerBatch,
pythonMetrics,
jobArtifactUUID,
sessionUUID,
@@ -68,6 +72,7 @@ class BatchEvalPythonEvaluatorFactory(
udfs: Seq[PythonUDF],
output: Seq[Attribute],
batchSize: Int,
+ maxBytesPerBatch: Long,
pythonMetrics: Map[String, SQLMetric],
jobArtifactUUID: Option[String],
sessionUUID: Option[String],
@@ -83,7 +88,8 @@ class BatchEvalPythonEvaluatorFactory(
EvaluatePython.registerPicklers() // register pickler for Row
// Input iterator to Python.
- val inputIterator = BatchEvalPythonExec.getInputIterator(iter, schema,
batchSize, binaryAsBytes)
+ val inputIterator = BatchEvalPythonExec.getInputIterator(
+ iter, schema, batchSize, binaryAsBytes, maxBytesPerBatch, pythonMetrics)
// Output iterator for results from Python.
val outputIterator =
@@ -123,7 +129,12 @@ object BatchEvalPythonExec {
iter: Iterator[InternalRow],
schema: StructType,
batchSize: Int,
- binaryAsBytes: Boolean): Iterator[Array[Byte]] = {
+ binaryAsBytes: Boolean,
+ maxBytesPerBatch: Long = -1L,
+ pythonMetrics: Map[String, SQLMetric] = Map.empty):
Iterator[Array[Byte]] = {
+ val peakPickledBatchBytesMetric =
pythonMetrics.get("pythonPeakPickledBatchBytes")
+ val oversizedBatchMetric = pythonMetrics.get("pythonOversizedBatchCount")
+ val estimatedInputBytesMetric =
pythonMetrics.get("pythonEstimatedInputBytes")
val dataTypes = schema.map(_.dataType)
val needConversion =
dataTypes.exists(EvaluatePython.needConversionInPython)
@@ -140,22 +151,66 @@ object BatchEvalPythonExec {
// Please note that cache by reference is still enabled depending on
`needConversion`.
val pickle = new Pickler(/* useMemo = */ needConversion,
/* valueCompare = */ false)
- // Input iterator to Python: input rows are grouped so we send them in
batches to Python.
- // For each row, add it to the queue.
- iter.map { row =>
+
+ // Converts a row to the java object pickled to Python. When `sizeAcc` is
defined, toJava also
+ // accumulates the per-row pickled-size estimate at its leaf cases during
this same traversal
+ // (no second walk).
+ def convertRow(row: InternalRow, sizeAcc: Option[PickledSizeAccumulator]):
Any = {
if (needConversion) {
- EvaluatePython.toJava(row, schema, binaryAsBytes)
+ EvaluatePython.toJava(row, schema, binaryAsBytes, sizeAcc)
} else {
// fast path for these types that does not need conversion in Python
+ sizeAcc.foreach(_.add(PickledSizeAccumulator.PER_VALUE_OVERHEAD))
val fields = new Array[Any](row.numFields)
var i = 0
while (i < row.numFields) {
val dt = dataTypes(i)
- fields(i) = EvaluatePython.toJava(row.get(i, dt), dt, binaryAsBytes)
+ fields(i) = EvaluatePython.toJava(row.get(i, dt), dt, binaryAsBytes,
sizeAcc)
i += 1
}
fields
}
- }.grouped(batchSize).map(x => pickle.dumps(x.toArray))
+ }
+
+ // Input iterator to Python: input rows are grouped so we send them in
batches to Python.
+ val batchedIter: Iterator[Array[Any]] =
+ if (maxBytesPerBatch < 0) {
+ // No byte limit configured (the default, -1): preserve exact
row-count batching behavior.
+ iter.map(convertRow(_, None)).grouped(batchSize).map(_.toArray)
+ } else {
+ // A finite byte cap is set, so batch by both row count and estimated
bytes (see
+ // ByteBoundedAsArrayIterator). The size is estimated on the converted
values (the things
+ // actually pickled), accounted by toJava during conversion itself;
estimator accuracy is
+ // observable via pythonEstimatedInputBytes vs pythonDataSent.
+ val sizeAcc = new PickledSizeAccumulator
+ val someSizeAcc = Some(sizeAcc)
+ new ByteBoundedAsArrayIterator(
+ iter.map { row =>
+ val converted = convertRow(row, someSizeAcc)
+ (converted, sizeAcc.getAndReset())
+ },
+ batchSize,
+ maxBytesPerBatch,
+ oversizedBatchMetric,
+ estimatedInputBytesMetric)
+ }
+
+ batchedIter.map { objects =>
+ val baos = new ByteArrayOutputStream(1024)
+ pickle.dump(objects, baos)
+ // Record the peak pickled-batch size: the primary contiguous JVM-heap
allocation on this
+ // path, and one oversized batch can OOM the executor. Read baos.size()
BEFORE the
+ // toByteArray copy, so the measurement does not depend on that second
contiguous allocation
+ // succeeding (a near-limit batch would OOM there, otherwise losing the
metric). The
+ // `> value` guard keeps the per-task max (`set` overwrites); SQLMetric
surfaces the
+ // cross-task max in the UI's (min, med, max) breakdown, mirroring how
peakMemory is reported.
+ peakPickledBatchBytesMetric.foreach { metric =>
+ val pickledBytes = baos.size().toLong
+ if (pickledBytes > metric.value) {
+ metric.set(pickledBytes)
+ }
+ }
+ baos.toByteArray
+ }
}
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonUDTFExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonUDTFExec.scala
index 9a7500d1b4cb..a2d94c226b7f 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonUDTFExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonUDTFExec.scala
@@ -48,7 +48,7 @@ case class BatchEvalPythonUDTFExec(
requiredChildOutput: Seq[Attribute],
resultAttrs: Seq[Attribute],
child: SparkPlan)
- extends EvalPythonUDTFExec with PythonSQLMetrics {
+ extends EvalPythonUDTFExec with PythonPickleBatchMetrics {
private[this] val jobArtifactUUID =
JobArtifactSet.getCurrentJobArtifactState.map(_.uuid)
private[this] val sessionUUID = {
@@ -70,9 +70,12 @@ case class BatchEvalPythonUDTFExec(
EvaluatePython.registerPicklers() // register pickler for Row
// Input iterator to Python.
- // For Python UDTF, we don't have a separate configuration for the batch
size yet.
+ // For Python UDTF, we don't have a separate configuration for the batch
size yet, and no byte
+ // cap (maxBytesPerBatch stays at the -1 default). We still pass
pythonMetrics so the peak
+ // pickled-batch size is recorded: the UDTF path pickles through the same
contiguous-allocation
+ // code as BatchEvalPythonExec and carries the same OOM risk, so the
observability applies here.
val inputIterator = BatchEvalPythonExec.getInputIterator(
- iter, schema, 100, conf.pysparkBinaryAsBytes)
+ iter, schema, 100, conf.pysparkBinaryAsBytes, pythonMetrics =
pythonMetrics)
// Output iterator for results from Python.
val outputIterator =
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ByteBoundedAsArrayIterator.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ByteBoundedAsArrayIterator.scala
new file mode 100644
index 000000000000..5df731fbab2e
--- /dev/null
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ByteBoundedAsArrayIterator.scala
@@ -0,0 +1,128 @@
+/*
+ * 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.python
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.unsafe.types.VariantVal
+
+private[python] object PickledSizeAccumulator {
+ // Pickle framing overhead per value (opcode + length prefix), a coarse
constant.
+ val PER_VALUE_OVERHEAD = 5L
+
+ // Unknown leaf type (timestamp objects, intervals, ...): a small constant,
never zero,
+ // so the cap degrades to a loose bound instead of going blind.
+ val UNKNOWN_VALUE_SIZE = 32L
+}
+
+/**
+ * Accumulates a best-effort estimate of the pickled size of converted values,
fed by the leaf
+ * cases of [[EvaluatePython.toJava]] DURING conversion, so estimating costs
no second traversal
+ * of the fields. The source InternalRow cannot be used for sizing instead:
EvalPythonExec feeds
+ * this path from a MutableProjection whose target is a GenericInternalRow, so
an UnsafeRow-based
+ * source estimate never applies. String/binary/decimal/geo/variant payloads
dominate pickled size
+ * and are sized from their byte lengths at the catalyst leaves
(UTF8String.numBytes is the UTF-8
+ * byte count pickle writes); residual error (pickle overhead, memoization,
unknown leaves) is
+ * observable by comparing the pythonEstimatedInputBytes metric against the
measured pythonDataSent.
+ *
+ * Single-threaded: one instance per partition, reset per row via
[[getAndReset]].
+ */
+private[python] final class PickledSizeAccumulator {
+ import PickledSizeAccumulator._
+
+ private var _size = 0L
+
+ /** Adds raw bytes (container/framing overhead). */
+ def add(n: Long): Unit = _size += n
+
+ /** Adds a leaf value with a known payload size, plus the per-value framing
overhead. */
+ def addValue(payloadBytes: Long): Unit = _size += payloadBytes +
PER_VALUE_OVERHEAD
+
+ /** Adds a pass-through leaf (boxed primitives and anything else toJava
leaves untouched). */
+ def addLeaf(value: Any): Unit = value match {
+ case null => _size += 1L
+ case _: java.lang.Boolean => _size += 2L
+ case _: java.lang.Byte | _: java.lang.Short => _size += PER_VALUE_OVERHEAD
+ case _: java.lang.Number => _size += 4L + PER_VALUE_OVERHEAD //
Int/Long/Float/Double box
+ case v: VariantVal =>
+ // Variant payloads can be arbitrarily large; size by the serialized
byte lengths.
+ _size += v.getValue.length.toLong + v.getMetadata.length.toLong +
PER_VALUE_OVERHEAD
+ case _ => _size += UNKNOWN_VALUE_SIZE
+ }
+
+ /** Returns the accumulated estimate and resets, closing one per-row
accounting cycle. */
+ def getAndReset(): Long = {
+ val s = _size
+ _size = 0L
+ s
+ }
+}
+
+/**
+ * Groups converted input objects into batches bounded by both a record count
and an estimated
+ * byte size (the second tuple element is the per-row estimate of the object's
pickled size, see
+ * [[PickledSizeAccumulator]], fed by toJava during conversion). A batch
always holds at least one
+ * row, so a single oversized row still forms a one-row batch.
+ *
+ * A batch is cut once its accumulated estimate reaches the cap, so it can
exceed the cap by
+ * the last row. Each cut batch increments `oversizedBatchMetric` once, and
+ * `estimatedInputBytesMetric` accumulates the per-row estimates for
comparison against the
+ * measured pythonDataSent (the estimator-accuracy signal).
+ */
+private[python] class ByteBoundedAsArrayIterator(
+ iter: Iterator[(Any, Long)],
+ maxRecordsPerBatch: Int,
+ maxBytesPerBatch: Long,
+ oversizedBatchMetric: Option[SQLMetric],
+ estimatedInputBytesMetric: Option[SQLMetric])
+ extends Iterator[Array[Any]] {
+
+ // Parity with the row-count batching path: a non-positive limit would loop
forever emitting
+ // empty batches. The config already enforces `> 0`; this is defensive.
+ require(maxRecordsPerBatch > 0, "max records per batch must be positive")
+
+ // Only finite positive caps may reach this class (BatchEvalPythonExec
routes the -1 "no limit"
+ // sentinel to the row-count-only batcher, and the config rejects 0).
Defensive: a cap of 0
+ // would silently degrade every batch to a single row instead of failing
loudly.
+ require(maxBytesPerBatch > 0, "max bytes per batch must be positive")
+
+ override def hasNext: Boolean = iter.hasNext
+
+ override def next(): Array[Any] = {
+ if (!hasNext) {
+ throw new NoSuchElementException
+ }
+ val batch = new ArrayBuffer[Any]()
+ var accumulatedBytes = 0L
+ var cut = false
+ while (!cut && iter.hasNext && batch.length < maxRecordsPerBatch) {
+ val (obj, sizeBytes) = iter.next()
+ // Sum the raw estimate for estimator-accuracy analysis (vs
pythonDataSent).
+ estimatedInputBytesMetric.foreach(_ += sizeBytes)
+ batch += obj
+ accumulatedBytes += sizeBytes
+ if (accumulatedBytes >= maxBytesPerBatch) {
+ cut = true
+ }
+ }
+ // Count each batch cut at the byte limit once.
+ if (cut) oversizedBatchMetric.foreach(_ += 1)
+ batch.toArray
+ }
+}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvaluatePython.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvaluatePython.scala
index cb1acd2f541b..3b9c2a3e69cd 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvaluatePython.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvaluatePython.scala
@@ -59,56 +59,86 @@ object EvaluatePython {
/**
* Helper for converting from Catalyst type to java type suitable for Pickle.
+ *
+ * When `sizeAcc` is defined, the conversion additionally accumulates a
best-effort estimate of
+ * the PICKLED size of the converted value, accounted at the leaf cases
during the same
+ * traversal (a separate estimation pass would walk every field a second
time). Catalyst leaves
+ * carry exact payload sizes (UTF8String.numBytes is the UTF-8 byte count
that pickle writes;
+ * Decimal.precision tracks the digit string). The estimate is best-effort:
unknown leaf types
+ * contribute a small positive constant, and residual error is observable by
comparing the
+ * pythonEstimatedInputBytes metric against pythonDataSent. INVARIANT: every
leaf case must
+ * account something positive to sizeAcc -- a case that converts without
accounting makes the
+ * byte cap (see [[BatchEvalPythonExec.getInputIterator]]) blind to that
type. The type sweep in
+ * BatchEvalPythonExecSuite ("estimate is positive for every data type")
enforces this.
*/
def toJava(
obj: Any,
dataType: DataType,
- binaryAsBytes: Boolean): Any = {
+ binaryAsBytes: Boolean,
+ sizeAcc: Option[PickledSizeAccumulator] = None): Any = {
(obj, dataType) match {
- case (null, _) => null
+ case (null, _) =>
+ sizeAcc.foreach(_.add(1L))
+ null
case (row: InternalRow, struct: StructType) =>
+ sizeAcc.foreach(_.add(PickledSizeAccumulator.PER_VALUE_OVERHEAD))
val values = new Array[Any](row.numFields)
var i = 0
while (i < row.numFields) {
val field = struct.fields(i)
- values(i) = toJava(row.get(i, field.dataType), field.dataType,
binaryAsBytes)
+ values(i) = toJava(row.get(i, field.dataType), field.dataType,
binaryAsBytes, sizeAcc)
i += 1
}
new GenericRowWithSchema(values, struct)
case (a: ArrayData, array: ArrayType) =>
+ sizeAcc.foreach(_.add(PickledSizeAccumulator.PER_VALUE_OVERHEAD))
val values = new java.util.ArrayList[Any](a.numElements())
a.foreach(array.elementType, (_, e) => {
- values.add(toJava(e, array.elementType, binaryAsBytes))
+ values.add(toJava(e, array.elementType, binaryAsBytes, sizeAcc))
})
values
case (map: MapData, mt: MapType) =>
+ sizeAcc.foreach(_.add(PickledSizeAccumulator.PER_VALUE_OVERHEAD))
val jmap = new java.util.HashMap[Any, Any](map.numElements())
map.foreach(mt.keyType, mt.valueType, (k, v) => {
- jmap.put(toJava(k, mt.keyType, binaryAsBytes), toJava(v,
mt.valueType, binaryAsBytes))
+ jmap.put(toJava(k, mt.keyType, binaryAsBytes, sizeAcc),
+ toJava(v, mt.valueType, binaryAsBytes, sizeAcc))
})
jmap
- case (ud, udt: UserDefinedType[_]) => toJava(ud, udt.sqlType,
binaryAsBytes)
+ case (ud, udt: UserDefinedType[_]) => toJava(ud, udt.sqlType,
binaryAsBytes, sizeAcc)
- case (d: Decimal, _) => d.toJavaBigDecimal
+ case (d: Decimal, _) =>
+ sizeAcc.foreach(_.addValue(d.precision.toLong))
+ d.toJavaBigDecimal
- case (s: UTF8String, _: StringType) => s.toString
+ case (s: UTF8String, _: StringType) =>
+ sizeAcc.foreach(_.addValue(s.numBytes.toLong))
+ s.toString
- case (g: BinaryView, gt: GeometryType) => STUtils.deserializeGeom(g, gt)
+ case (g: BinaryView, gt: GeometryType) =>
+ // Geometry payloads can be arbitrarily large; size by the serialized
byte length.
+ sizeAcc.foreach(_.addValue(g.numBytes.toLong))
+ STUtils.deserializeGeom(g, gt)
- case (g: BinaryView, gt: GeographyType) => STUtils.deserializeGeog(g, gt)
+ case (g: BinaryView, gt: GeographyType) =>
+ sizeAcc.foreach(_.addValue(g.numBytes.toLong))
+ STUtils.deserializeGeog(g, gt)
case (bytes: Array[Byte], BinaryType) =>
+ sizeAcc.foreach(_.addValue(bytes.length.toLong))
if (binaryAsBytes) {
new BytesWrapper(bytes)
} else {
bytes
}
- case (other, _) => other
+ case (other, _) =>
+ sizeAcc.foreach(_.addLeaf(other))
+ other
}
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonSQLMetrics.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonSQLMetrics.scala
index cbce07977f16..81df5aaad5c2 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonSQLMetrics.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonSQLMetrics.scala
@@ -28,9 +28,13 @@ trait PythonSQLMetrics { self: SparkPlan =>
k -> SQLMetrics.createTimingMetric(sparkContext, v)
} ++ PythonSQLMetrics.pythonOtherMetricsDesc.map { case (k, v) =>
k -> SQLMetrics.createMetric(sparkContext, v)
- }
+ } ++ additionalMetrics
}
+ // Hook for subtraits to add operator-specific metrics on top of the shared
ones above. Default
+ // empty so the shared trait stays minimal; see [[PythonPickleBatchMetrics]].
+ protected def additionalMetrics: Map[String, SQLMetric] = Map.empty
+
override lazy val metrics: Map[String, SQLMetric] = pythonMetrics
}
@@ -55,3 +59,46 @@ object PythonSQLMetrics {
Map("pythonNumRowsReceived" -> "number of output rows")
}
}
+
+/**
+ * Metrics specific to the regular (pickle-serialized) Python UDF input
batching path in
+ * [[BatchEvalPythonExec.getInputIterator]]. Mixed in only by the operators
that pickle their
+ * input ([[BatchEvalPythonExec]] and [[BatchEvalPythonUDTFExec]]); kept out
of the shared
+ * [[PythonSQLMetrics]] so they do not surface as always-zero rows on the
Arrow / streaming Python
+ * operators that mix in PythonSQLMetrics but never pickle through
getInputIterator.
+ *
+ * - pythonPeakPickledBatchBytes: peak per-batch pickled size (the primary
contiguous heap
+ * allocation on this path). Like peakMemory it is a SIZE metric, so the
figure to read is the
+ * per-partition max in the UI's min/med/max breakdown; the aggregated
total (the sum of the
+ * per-task peaks) is not meaningful on its own. Always recorded on the
pickle path.
+ * - pythonEstimatedInputBytes: running sum of the per-row size estimates
the byte cap uses,
+ * compared against the measured pythonDataSent to gauge estimator
accuracy. Only populated
+ * when a byte cap is configured.
+ * - pythonOversizedBatchCount: input batches cut at the byte cap, counted
once per cut batch.
+ * Only populated when a finite
spark.sql.execution.python.udf.maxBytesPerBatch is set.
+ *
+ * Also kept out of pythonSizeMetricsDesc, which feeds the Python data source
DSv2 metric
+ * declarations (UserDefinedPythonDataSource.createPythonMetrics) that never
populate these.
+ */
+trait PythonPickleBatchMetrics extends PythonSQLMetrics { self: SparkPlan =>
+ override protected def additionalMetrics: Map[String, SQLMetric] =
+ PythonPickleBatchMetrics.pickleBatchSizeMetricsDesc.map { case (k, v) =>
+ k -> SQLMetrics.createSizeMetric(sparkContext, v)
+ } ++ PythonPickleBatchMetrics.pickleBatchCountMetricsDesc.map { case (k,
v) =>
+ k -> SQLMetrics.createMetric(sparkContext, v)
+ }
+}
+
+object PythonPickleBatchMetrics {
+ // Peak pickled batch size and estimated input size, reported as SIZE
metrics.
+ val pickleBatchSizeMetricsDesc: Map[String, String] = {
+ Map(
+ "pythonPeakPickledBatchBytes" -> "peak pickled batch size in bytes",
+ "pythonEstimatedInputBytes" -> "estimated pickled input size in bytes")
+ }
+
+ // Count of batches cut at the byte cap, reported as a SUM metric.
+ val pickleBatchCountMetricsDesc: Map[String, String] = {
+ Map("pythonOversizedBatchCount" -> "number of batches cut at the byte
limit")
+ }
+}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
index a10689c1226b..42da9f060b85 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
@@ -21,13 +21,15 @@ import scala.collection.mutable.ArrayBuffer
import scala.jdk.CollectionConverters._
import org.apache.spark.api.python.{PythonEvalType, SimplePythonFunction}
-import org.apache.spark.sql.catalyst.FunctionIdentifier
-import org.apache.spark.sql.catalyst.expressions.{And, AttributeReference,
GreaterThan, In}
+import org.apache.spark.sql.catalyst.{FunctionIdentifier, InternalRow}
+import org.apache.spark.sql.catalyst.expressions.{And, AttributeReference,
GenericInternalRow, GreaterThan, In}
import org.apache.spark.sql.connector.catalog.CatalogManager
import org.apache.spark.sql.execution.{FilterExec, InputAdapter,
WholeStageCodegenExec}
import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
import org.apache.spark.sql.test.SharedSparkSession
-import org.apache.spark.sql.types.{BooleanType, DoubleType}
+import org.apache.spark.sql.types._
+import org.apache.spark.unsafe.types.UTF8String
class BatchEvalPythonExecSuite extends SharedSparkSession
with AdaptiveSparkPlanHelper {
@@ -52,6 +54,209 @@ class BatchEvalPythonExecSuite extends SharedSparkSession
}
}
+ test("SPARK-57593: getInputIterator records peak pickled batch size as a
size metric") {
+ EvaluatePython.registerPicklers()
+ val schema = StructType(Seq(StructField("s", StringType)))
+ // batchSize = 1 so each row is its own pickled batch; the widest row
yields the peak.
+ val batchSize = 1
+ val rows = Seq(
+ InternalRow(UTF8String.fromString("a")),
+ InternalRow(UTF8String.fromString("b" * 1000)),
+ InternalRow(UTF8String.fromString("c")))
+
+ // Baseline: the peak pickled-batch size, computed without instrumentation.
+ val expectedPeak = BatchEvalPythonExec
+ .getInputIterator(rows.iterator, schema, batchSize, binaryAsBytes =
false)
+ .map(_.length.toLong)
+ .max
+ assert(expectedPeak > 0)
+
+ val metric = SQLMetrics.createSizeMetric(spark.sparkContext, "peak pickled
batch size in bytes")
+ // Drain the iterator so every batch is pickled and measured.
+ BatchEvalPythonExec
+ .getInputIterator(rows.iterator, schema, batchSize, binaryAsBytes =
false,
+ pythonMetrics = Map("pythonPeakPickledBatchBytes" -> metric))
+ .foreach(_ => ())
+
+ assert(metric.value === expectedPeak)
+ }
+
+ test("SPARK-57593: ByteBoundedAsArrayIterator oversized-batch and
estimated-bytes metrics") {
+ def elems(sizes: Long*): Iterator[(Any, Long)] =
+ sizes.iterator.map(s => (0.asInstanceOf[Any], s))
+ def m(): SQLMetric = SQLMetrics.createMetric(spark.sparkContext, "m")
+
+ // Cut once accumulated bytes >= 25 (after 3 rows of 10).
estimatedInputBytes sums the
+ // per-row estimate across the whole partition.
+ val ov1 = m(); val est1 = m()
+ val byBytes = new ByteBoundedAsArrayIterator(
+ elems(10, 10, 10, 10, 10, 10, 10), maxRecordsPerBatch = 100,
maxBytesPerBatch = 25L,
+ Some(ov1), Some(est1)).toList.map(_.length)
+ assert(byBytes === List(3, 3, 1))
+ assert(ov1.value === 2) // two batches were cut at the cap; the trailing
1-row batch was not
+ assert(est1.value === 70) // sum of per-row estimates
+
+ // Record cap dominates when smaller; the byte limit is never reached.
+ val ov2 = m()
+ val byCount = new ByteBoundedAsArrayIterator(
+ elems(10, 10, 10, 10, 10), maxRecordsPerBatch = 2, maxBytesPerBatch =
1000L,
+ Some(ov2), None).toList.map(_.length)
+ assert(byCount === List(2, 2, 1))
+ assert(ov2.value === 0)
+
+ // A single row larger than the cap still forms a one-row batch (never
zero rows).
+ val ov3 = m()
+ val giant = new ByteBoundedAsArrayIterator(
+ elems(10000), maxRecordsPerBatch = 100, maxBytesPerBatch = 25L,
+ Some(ov3), None).toList.map(_.length)
+ assert(giant === List(1))
+ assert(ov3.value === 1)
+
+ // Size-0 estimates contribute nothing to the estimate sum or the byte
cap; the cut happens
+ // on the first row that pushes the estimate to the cap.
+ val ov5 = m(); val est5 = m()
+ val mixed = new ByteBoundedAsArrayIterator(
+ elems(0, 0, 30, 0), maxRecordsPerBatch = 100, maxBytesPerBatch = 25L,
+ Some(ov5), Some(est5)).toList.map(_.length)
+ assert(mixed === List(3, 1))
+ assert(est5.value === 30) // only the size-30 row contributes
+ assert(ov5.value === 1) // one batch cut at the cap
+ }
+
+ test("SPARK-57593: ByteBoundedAsArrayIterator honors the Iterator contract")
{
+ def elems(sizes: Long*): Iterator[(Any, Long)] =
+ sizes.iterator.map(s => (0.asInstanceOf[Any], s))
+ def iterOf(input: Iterator[(Any, Long)], maxRecords: Int = 100):
Iterator[Array[Any]] =
+ new ByteBoundedAsArrayIterator(
+ input, maxRecordsPerBatch = maxRecords, maxBytesPerBatch =
Int.MaxValue.toLong,
+ None, None)
+
+ // next() past the end throws NoSuchElementException, matching the
row-count batching path.
+ val it = iterOf(elems(10))
+ assert(it.hasNext)
+ assert(it.next().length === 1)
+ assert(!it.hasNext)
+ intercept[NoSuchElementException](it.next())
+
+ // An empty input yields no batches and next() throws immediately.
+ val empty = iterOf(elems())
+ assert(!empty.hasNext)
+ intercept[NoSuchElementException](empty.next())
+
+ // A non-positive record limit is rejected up front.
+ intercept[IllegalArgumentException](iterOf(elems(10), maxRecords = 0))
+
+ // A non-positive byte cap is rejected up front too: only finite positive
caps may reach this
+ // class (a cap of 0 would otherwise silently degrade every batch to a
single row).
+ intercept[IllegalArgumentException](new ByteBoundedAsArrayIterator(
+ elems(10), maxRecordsPerBatch = 100, maxBytesPerBatch = 0L, None, None))
+ }
+
+ test("SPARK-57593: getInputIterator byte-bounds pickle batches when a cap is
configured") {
+ EvaluatePython.registerPicklers()
+ val schema = StructType(Seq(StructField("s", StringType)))
+ // GenericInternalRow on purpose: production feeds this path from a
MutableProjection whose
+ // target is a GenericInternalRow (never an UnsafeRow), so the byte cap
must work on it.
+ def wideRows(): Iterator[InternalRow] = (0 until 10).iterator.map { _ =>
+ new GenericInternalRow(Array[Any](UTF8String.fromString("x" * 1000)))
+ }
+
+ // Default: no byte cap, so all 10 rows form a single batch (batchSize =
100).
+ val defaultBatches = BatchEvalPythonExec
+ .getInputIterator(wideRows(), schema, batchSize = 100, binaryAsBytes =
false).size
+ assert(defaultBatches === 1)
+
+ // Finite cap: the wide rows split into multiple smaller batches, and the
estimate metric is
+ // populated (regression test: the estimate must be non-zero for
production row classes).
+ val estMetric = SQLMetrics.createSizeMetric(spark.sparkContext, "est")
+ val cappedBatches = BatchEvalPythonExec
+ .getInputIterator(wideRows(), schema, batchSize = 100, binaryAsBytes =
false,
+ maxBytesPerBatch = 2048L,
+ pythonMetrics = Map("pythonEstimatedInputBytes" -> estMetric)).size
+ assert(cappedBatches > defaultBatches)
+ assert(estMetric.value >= 10 * 1000L) // 10 rows of a 1000-char string,
plus overhead
+ }
+
+ test("SPARK-57593: getInputIterator does not byte-bound when
maxBytesPerBatch is -1 (no limit)") {
+ EvaluatePython.registerPicklers()
+ val schema = StructType(Seq(StructField("s", StringType)))
+ // 250 wide rows: more than batchSize so row-count batching genuinely
splits them, and each row
+ // is wide enough that any finite byte cap would split them into far more
batches (the sibling
+ // test above cuts 10 of these at a 2048-byte cap).
+ def wideRows(): Iterator[InternalRow] = (0 until 250).iterator.map { _ =>
+ new GenericInternalRow(Array[Any](UTF8String.fromString("x" * 1000)))
+ }
+
+ // -1 (the default) routes to the row-count-only batcher: batches are cut
purely at batchSize
+ // (250 rows / 100 = 3 batches) with no byte bound, and the byte-only
metrics stay unpopulated.
+ val estMetric = SQLMetrics.createSizeMetric(spark.sparkContext, "est")
+ val oversizedMetric = SQLMetrics.createMetric(spark.sparkContext, "ov")
+ val batches = BatchEvalPythonExec
+ .getInputIterator(wideRows(), schema, batchSize = 100, binaryAsBytes =
false,
+ maxBytesPerBatch = -1L,
+ pythonMetrics = Map(
+ "pythonEstimatedInputBytes" -> estMetric,
+ "pythonOversizedBatchCount" -> oversizedMetric)).size
+ // Row-count only: 3 batches (100 + 100 + 50). A finite byte cap would
produce many more.
+ assert(batches === 3)
+ assert(estMetric.value === 0L) // no per-row estimate accumulated on
the no-limit path
+ assert(oversizedMetric.value === 0L) // no batch cut at a byte limit
+ }
+
+ test("SPARK-57593: toJava accumulates the pickled-size estimate during
conversion") {
+ val acc = new PickledSizeAccumulator
+ def sized(value: Any, dataType: DataType): Long = {
+ EvaluatePython.toJava(value, dataType, binaryAsBytes = true, Some(acc))
+ acc.getAndReset()
+ }
+ // Payload bytes dominate the estimate; UTF8String.numBytes is the exact
UTF-8 byte count.
+ val wide = sized(UTF8String.fromString("x" * 10000), StringType)
+ assert(wide >= 10000L && wide <= 10000L + 64L)
+ val binary = sized(Array.fill[Byte](5000)(1), BinaryType)
+ assert(binary >= 5000L && binary <= 5000L + 64L)
+ // Decimals expand into digit strings when pickled; the estimate tracks
precision.
+ val dec = sized(Decimal(BigDecimal("1234567890.123456789")),
DecimalType(38, 18))
+ assert(dec >= 19L)
+ // Nulls and unknown leaf types yield small positive estimates -- never
zero, so the cap
+ // can never go blind on an unrecognized row shape.
+ assert(sized(null, StringType) > 0L)
+ assert(sized(new Object, CalendarIntervalType) > 0L)
+ // Whole-row conversion (needConversion path): nested values are sized in
the same pass,
+ // and getAndReset() closes the per-row cycle.
+ val schema = StructType(Seq(StructField("s", StringType), StructField("n",
StringType)))
+ val rowSize = sized(
+ new GenericInternalRow(Array[Any](UTF8String.fromString("y" * 2000),
null)), schema)
+ assert(rowSize >= 2000L)
+ assert(acc.getAndReset() === 0L) // reset really resets
+ }
+
+ test("SPARK-57593: toJava size estimate is positive for every data type
(drift guard)") {
+ // Enforces the accounting invariant on EvaluatePython.toJava: a (new)
conversion case that
+ // forgets to account to sizeAcc yields a zero estimate at value level and
makes the byte cap
+ // blind to that type. The type list comes from DataTypeTestUtils +
composites, so the sweep
+ // extends as Spark grows types, and values come from the seeded
RandomDataGenerator.
+ import org.apache.spark.sql.RandomDataGenerator
+ import org.apache.spark.sql.catalyst.CatalystTypeConverters
+ val acc = new PickledSizeAccumulator
+ val sweep = (DataTypeTestUtils.atomicTypes.toSeq ++ Seq(
+ ArrayType(StringType),
+ MapType(StringType, DoubleType),
+ StructType(Seq(StructField("s", StringType), StructField("d",
DoubleType)))))
+ var swept = 0
+ sweep.foreach { dt =>
+ RandomDataGenerator.forType(dt, nullable = false, new
scala.util.Random(42)).foreach {
+ gen =>
+ val catalystValue =
CatalystTypeConverters.createToCatalystConverter(dt)(gen())
+ EvaluatePython.toJava(catalystValue, dt, binaryAsBytes = true,
Some(acc))
+ val estimate = acc.getAndReset()
+ assert(estimate > 0L, s"toJava accounted nothing for $dt -- the byte
cap is blind " +
+ "to this type; add accounting to the corresponding case")
+ swept += 1
+ }
+ }
+ assert(swept > 10) // the sweep must be exercising a meaningful slice of
the type space
+ }
+
test("Python UDF: push down deterministic FilterExec predicates") {
val df = Seq(("Hello", 4)).toDF("a", "b")
.where("dummyPythonUDF(b) and dummyPythonUDF(a) and a in (3, 4)")
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
index 22584231b328..dfff8ed9e975 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
@@ -265,4 +265,36 @@ class PythonUDFSuite extends SharedSparkSession {
}
}
}
+
+ test("SPARK-57593: pythonPeakPickledBatchBytes metric for
BatchEvalPythonUDTFExec") {
+ assume(shouldTestPythonUDFs)
+ val udtf = TestPythonUDTF(name = "test_udtf")
+
+ spark.udtf.registerPython(udtf.name, udtf.udtf)
+ withTempView("t") {
+ try {
+ spark.range(1000).selectExpr("id % 100 as a", "id % 50 as b")
+ .createOrReplaceTempView("t")
+ val result = sql(s"SELECT f.* FROM t, LATERAL ${udtf.name}(a, b) f")
+ result.collect()
+
+ val udtfExec = result.queryExecution.executedPlan.collectFirst {
+ case p: BatchEvalPythonUDTFExec => p
+ }.getOrElse {
+ fail("Expected BatchEvalPythonUDTFExec in executed plan")
+ }
+
+ // The UDTF pickle path pickles its input through the same
contiguous-allocation code as
+ // BatchEvalPythonExec, so the peak pickled-batch size is recorded
here too (passed via the
+ // shared pythonMetrics map) even though the UDTF path sets no byte
cap.
+ val peakPickledBatchBytes =
+
udtfExec.metrics.get("pythonPeakPickledBatchBytes").map(_.value).getOrElse(0L)
+ assert(peakPickledBatchBytes > 0,
+ "pythonPeakPickledBatchBytes should be > 0 for
BatchEvalPythonUDTFExec, " +
+ s"but was $peakPickledBatchBytes")
+ } finally {
+ spark.sessionState.catalog.dropTempFunction(udtf.name,
ignoreIfNotExists = true)
+ }
+ }
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]