This is an automated email from the ASF dual-hosted git repository.
gaogaotiantian pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 2092cf6cbeae [SPARK-59773][PYTHON] Report Python worker metrics as JSON
2092cf6cbeae is described below
commit 2092cf6cbeaef96363edfbab7927598d3aba1ea7
Author: Spenser Sun <[email protected]>
AuthorDate: Wed Sep 30 16:37:03 2026 -0700
[SPARK-59773][PYTHON] Report Python worker metrics as JSON
### What changes were proposed in this pull request?
- Reuse `-3` as `METRICS_DATA` and send existing worker timestamps,
execution duration, and spill totals in one length-prefixed UTF-8 JSON object.
- Parse and validate the six fields inside `readWorkerMetrics`, returning
`WorkerMetrics` to the shared JVM handler.
- Preserve SQL metric keys, including `pythonProcessingTime` for
`pythonExecutionDurationMs`, and the existing initialization accounting for
reused workers.
### Why are the changes needed?
Named fields make reports extensible for
[SPARK-59772](https://issues.apache.org/jira/browse/SPARK-59772). Additional
measurements and collection wrappers remain separate work.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
- Added JSON-reader checks to `BasePythonRunnerSuite` and driver-side spill
assertions to `pyspark.tests.test_worker`; existing UDF tests exercise worker
communication and SQL metrics.
- Worker and Arrow tests, focused Scala/SQL tests, Ruff, mypy, and
Scalastyle passed. The rebased tree passed the protocol and UDF metrics tests,
including reused-worker initialization; local Scala tests used LZ4 1.11.3
because 1.12.0 was unavailable.
- All 24 `bench_eval_type.ArrowBatchedUDFTimeBench` parameter combinations
passed an ASV `--quick` compatibility run locally.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: OpenAI Codex (GPT-6, GPT-5)
Closes #59019 from Spenserrrr/udf-metrics-protocol.
Authored-by: Spenser Sun <[email protected]>
Signed-off-by: Tian Gao <[email protected]>
(cherry picked from commit e85102e700708123923b8fa1309190a42f3634bb)
Signed-off-by: Tian Gao <[email protected]>
---
.../org/apache/spark/api/python/PythonRunner.scala | 76 ++++++++++---
.../spark/api/python/BasePythonRunnerSuite.scala | 122 +++++++++++++++++++++
python/pyspark/serializers.py | 2 +-
python/pyspark/sql/pandas/serializers.py | 2 +-
python/pyspark/tests/test_worker.py | 20 ++++
python/pyspark/worker.py | 43 +++++---
.../sql/execution/python/PythonArrowOutput.scala | 8 +-
.../sql/execution/python/PythonUDFRunner.scala | 4 +-
8 files changed, 242 insertions(+), 35 deletions(-)
diff --git a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
index 4b9f5b3d7f1b..0f58098d79b3 100644
--- a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
+++ b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
@@ -29,6 +29,9 @@ import scala.jdk.CollectionConverters._
import scala.util.{Success, Try}
import scala.util.control.NonFatal
+import com.fasterxml.jackson.core.{JsonParser, JsonProcessingException}
+import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
+
import org.apache.spark._
import org.apache.spark.api.python.PythonFunction.PythonAccumulator
import org.apache.spark.internal.{Logging, MessageWithContext}
@@ -210,6 +213,53 @@ private[spark] object BasePythonRunner extends Logging {
}
}
+ // Values used to update the existing Python SQL metrics and task spill
metrics.
+ private[python] case class WorkerMetrics(
+ bootTimestampMs: Long,
+ initTimestampMs: Long,
+ finishTimestampMs: Long,
+ pythonExecutionDurationMs: Long,
+ memoryBytesSpilled: Long,
+ diskBytesSpilled: Long)
+
+ private lazy val workerMetricsMapper = new ObjectMapper()
+ .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
+ .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+
+ /** Read and validate worker metrics after METRICS_DATA; ignore additional
fields. */
+ private[python] def readWorkerMetrics(stream: DataInputStream):
WorkerMetrics = {
+ val length = stream.readInt()
+ if (length <= 0) {
+ throw new SparkException(s"Invalid Python worker report length: $length")
+ }
+ val json = PythonWorkerUtils.readUTF(length, stream)
+ val report = try {
+ workerMetricsMapper.readTree(json)
+ } catch {
+ case e: JsonProcessingException =>
+ throw new SparkException("Malformed Python worker report JSON", e)
+ }
+ if (report == null || !report.isObject) {
+ throw new SparkException("Expected a Python worker JSON object")
+ }
+
+ def metricValue(name: String): Long = {
+ val value = report.get(name)
+ if (value == null || !value.isIntegralNumber || !value.canConvertToLong)
{
+ throw new SparkException(s"Missing or invalid Python worker metric:
$name")
+ }
+ value.longValue()
+ }
+
+ WorkerMetrics(
+ metricValue("bootTimestampMs"),
+ metricValue("initTimestampMs"),
+ metricValue("finishTimestampMs"),
+ metricValue("pythonExecutionDurationMs"),
+ metricValue("memoryBytesSpilled"),
+ metricValue("diskBytesSpilled"))
+ }
+
/**
* Creates a task identifier string for logging following Spark's standard
format.
* Format: "task <partition>.<attempt> in stage <stageId> (TID
<taskAttemptId>)"
@@ -857,12 +907,12 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
*/
protected def read(): OUT
- protected def handleTimingData(): Unit = {
- // Timing data from worker
- val bootTime = stream.readLong()
- val initTime = stream.readLong()
- val finishTime = stream.readLong()
- val processingTimeMs = stream.readLong()
+ protected def handleMetricsData(): Unit = {
+ val workerMetrics = BasePythonRunner.readWorkerMetrics(stream)
+ val bootTime = workerMetrics.bootTimestampMs
+ val initTime = workerMetrics.initTimestampMs
+ val finishTime = workerMetrics.finishTimestampMs
+ val pythonExecutionDurationMs = workerMetrics.pythonExecutionDurationMs
// A reused Python worker records bootTime before waiting for this task,
so it can precede
// startTime. Use the later timestamp to exclude the worker's idle time
from initialization.
val pythonWorkerInitializationStartTime = math.max(startTime, bootTime)
@@ -888,11 +938,9 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
metrics.get("pythonBootTime").foreach(_.add(boot))
metrics.get("pythonInitTime").foreach(_.add(init))
metrics.get("pythonTotalTime").foreach(_.add(total))
- metrics.get("pythonProcessingTime").foreach(_.add(processingTimeMs))
- val memoryBytesSpilled = stream.readLong()
- val diskBytesSpilled = stream.readLong()
- context.taskMetrics().incMemoryBytesSpilled(memoryBytesSpilled)
- context.taskMetrics().incDiskBytesSpilled(diskBytesSpilled)
+
metrics.get("pythonProcessingTime").foreach(_.add(pythonExecutionDurationMs))
+
context.taskMetrics().incMemoryBytesSpilled(workerMetrics.memoryBytesSpilled)
+ context.taskMetrics().incDiskBytesSpilled(workerMetrics.diskBytesSpilled)
}
protected def handlePythonException(): PythonException = {
@@ -1398,8 +1446,8 @@ private[spark] class PythonRunner(
batchesProcessed += 1
totalDataReceived += length
data
- case SpecialLengths.TIMING_DATA =>
- handleTimingData()
+ case SpecialLengths.METRICS_DATA =>
+ handleMetricsData()
read()
case SpecialLengths.PYTHON_EXCEPTION_THROWN =>
throw handlePythonException()
@@ -1422,7 +1470,7 @@ class PythonWorkerException(msg: String, cause: Throwable)
private[spark] object SpecialLengths {
val END_OF_DATA_SECTION = -1
val PYTHON_EXCEPTION_THROWN = -2
- val TIMING_DATA = -3
+ val METRICS_DATA = -3
val END_OF_STREAM = -4
val NULL = -5
val START_ARROW_STREAM = -6
diff --git
a/core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala
b/core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala
index 5477e8f7488c..f7675880e01f 100644
---
a/core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala
+++
b/core/src/test/scala/org/apache/spark/api/python/BasePythonRunnerSuite.scala
@@ -17,6 +17,8 @@
package org.apache.spark.api.python
+import java.io.{ByteArrayInputStream, ByteArrayOutputStream, DataInputStream,
DataOutputStream, EOFException}
+import java.nio.charset.StandardCharsets
import java.util.concurrent.{CountDownLatch, TimeUnit}
import org.apache.spark.{SparkException, SparkFunSuite}
@@ -89,4 +91,124 @@ class BasePythonRunnerSuite extends SparkFunSuite {
BasePythonRunner.getWorkerMemoryMb(Some(1L), 2)
}
}
+
+ private val metricsReport =
+ "{\"bootTimestampMs\":1250,\"initTimestampMs\":2500," +
+ "\"finishTimestampMs\":3750,\"pythonExecutionDurationMs\":42," +
+ "\"memoryBytesSpilled\":7,\"diskBytesSpilled\":9}"
+ private val expectedMetrics = BasePythonRunner.WorkerMetrics(1250L, 2500L,
3750L, 42L, 7L, 9L)
+
+ private def framedMetricsStream(json: String, declaredLength: Option[Int] =
None,
+ endMarkers: Boolean = true): DataInputStream = {
+ val bytes = json.getBytes(StandardCharsets.UTF_8)
+ val buffer = new ByteArrayOutputStream()
+ val output = new DataOutputStream(buffer)
+ output.writeInt(declaredLength.getOrElse(bytes.length))
+ output.write(bytes)
+ if (endMarkers) {
+ output.writeInt(SpecialLengths.END_OF_DATA_SECTION)
+ output.writeInt(0) // No accumulator updates.
+ output.writeInt(SpecialLengths.END_OF_STREAM)
+ }
+ new DataInputStream(new ByteArrayInputStream(buffer.toByteArray))
+ }
+
+ private def checkEndMarkers(stream: DataInputStream): Unit = {
+ assert(stream.readInt() == SpecialLengths.END_OF_DATA_SECTION)
+ assert(stream.readInt() == 0)
+ assert(stream.readInt() == SpecialLengths.END_OF_STREAM)
+ assert(stream.read() == -1)
+ }
+
+ test("SPARK-59773: read worker metrics and leave the following sections
aligned") {
+ val withExtra = metricsReport.dropRight(1) +
",\"futureMetric\":{\"value\":[1,2]}}"
+ val stream = framedMetricsStream(withExtra)
+ assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
+ checkEndMarkers(stream)
+ }
+
+ test("SPARK-59773: worker metrics require all current fields") {
+ val fields = Seq(
+ "bootTimestampMs" -> "\"bootTimestampMs\":1250,",
+ "initTimestampMs" -> "\"initTimestampMs\":2500,",
+ "finishTimestampMs" -> "\"finishTimestampMs\":3750,",
+ "pythonExecutionDurationMs" -> "\"pythonExecutionDurationMs\":42,",
+ "memoryBytesSpilled" -> "\"memoryBytesSpilled\":7,",
+ "diskBytesSpilled" -> ",\"diskBytesSpilled\":9")
+ fields.foreach { case (name, field) =>
+ val stream = framedMetricsStream(metricsReport.replace(field, ""))
+ val error = intercept[SparkException] {
+ BasePythonRunner.readWorkerMetrics(stream)
+ }
+ assert(error.getMessage.contains(name))
+ checkEndMarkers(stream)
+ }
+ }
+
+ test("SPARK-59773: worker metrics reject non-int64 values") {
+ val invalidValues = Seq("null", "true", "\"42\"", "42.0", "[]", "{}",
+ "9223372036854775808", "-9223372036854775809")
+ val fields = Seq("pythonExecutionDurationMs" -> 42,
+ "memoryBytesSpilled" -> 7, "diskBytesSpilled" -> 9)
+ for {
+ (name, originalValue) <- fields
+ value <- invalidValues
+ } {
+ val field = "\"" + name + "\":"
+ val json = metricsReport.replace(field + originalValue, field + value)
+ val stream = framedMetricsStream(json)
+ val error = intercept[SparkException] {
+ BasePythonRunner.readWorkerMetrics(stream)
+ }
+ assert(error.getMessage.contains(name))
+ checkEndMarkers(stream)
+ }
+ }
+
+ test("SPARK-59773: worker metrics preserve int64 values exactly") {
+ Seq(Long.MinValue, 9007199254740993L, Long.MaxValue).foreach { value =>
+ val json = metricsReport.replace(
+ "\"pythonExecutionDurationMs\":42", "\"pythonExecutionDurationMs\":" +
value)
+ .replace("\"memoryBytesSpilled\":7", "\"memoryBytesSpilled\":" + value)
+ .replace("\"diskBytesSpilled\":9", "\"diskBytesSpilled\":" + value)
+ val stream = framedMetricsStream(json)
+ assert(BasePythonRunner.readWorkerMetrics(stream) ==
+ expectedMetrics.copy(pythonExecutionDurationMs = value,
+ memoryBytesSpilled = value, diskBytesSpilled = value))
+ checkEndMarkers(stream)
+ }
+ }
+
+ test("SPARK-59773: worker metrics reject malformed, duplicate, and
non-object JSON") {
+ val invalid = Seq("[]", "null", "42", metricsReport.dropRight(1),
metricsReport + "{}",
+ metricsReport.dropRight(1) + ",\"bootTimestampMs\":9}",
"{\"future\":1,\"future\":2}")
+ invalid.foreach { json =>
+ val stream = framedMetricsStream(json)
+ intercept[SparkException] {
+ BasePythonRunner.readWorkerMetrics(stream)
+ }
+ checkEndMarkers(stream)
+ }
+ }
+
+ test("SPARK-59773: worker metrics reject invalid lengths and truncated
frames") {
+ Seq(-1, 0).foreach { length =>
+ intercept[SparkException] {
+ BasePythonRunner.readWorkerMetrics(framedMetricsStream(metricsReport,
Some(length)))
+ }
+ }
+
+ val truncated = framedMetricsStream(metricsReport,
+ Some(metricsReport.getBytes(StandardCharsets.UTF_8).length + 1),
endMarkers = false)
+ intercept[EOFException] {
+ BasePythonRunner.readWorkerMetrics(truncated)
+ }
+ }
+
+ test("SPARK-59773: worker metrics accept a larger report with an additional
field") {
+ val json = metricsReport.dropRight(1) + ",\"future\":\"" + ("x" * (70 *
1024)) + "\"}"
+ val stream = framedMetricsStream(json)
+ assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
+ checkEndMarkers(stream)
+ }
}
diff --git a/python/pyspark/serializers.py b/python/pyspark/serializers.py
index 4266020ee999..ca6aecbd706d 100644
--- a/python/pyspark/serializers.py
+++ b/python/pyspark/serializers.py
@@ -88,7 +88,7 @@ __all__ = [
class SpecialLengths:
END_OF_DATA_SECTION = -1
PYTHON_EXCEPTION_THROWN = -2
- TIMING_DATA = -3
+ METRICS_DATA = -3
END_OF_STREAM = -4
NULL = -5
START_ARROW_STREAM = -6
diff --git a/python/pyspark/sql/pandas/serializers.py
b/python/pyspark/sql/pandas/serializers.py
index 9eed884dc7b7..6e70e69fa2f4 100644
--- a/python/pyspark/sql/pandas/serializers.py
+++ b/python/pyspark/sql/pandas/serializers.py
@@ -36,7 +36,7 @@ if TYPE_CHECKING:
class SpecialLengths:
END_OF_DATA_SECTION = -1
PYTHON_EXCEPTION_THROWN = -2
- TIMING_DATA = -3
+ METRICS_DATA = -3
END_OF_STREAM = -4
NULL = -5
START_ARROW_STREAM = -6
diff --git a/python/pyspark/tests/test_worker.py
b/python/pyspark/tests/test_worker.py
index 79df02daf9ea..402821a75288 100644
--- a/python/pyspark/tests/test_worker.py
+++ b/python/pyspark/tests/test_worker.py
@@ -145,6 +145,26 @@ class WorkerTests(ReusedPySparkTestCase):
self.assertEqual(sum(range(100)), acc2.value)
self.assertEqual(sum(range(100)), acc1.value)
+ def test_worker_metrics_include_spills(self):
+ accumulator = self.sc.accumulator(0)
+
+ def increment(value):
+ from pyspark import TaskContext, shuffle
+
+ # Supply known spill totals to check their transport into JVM task
metrics.
+ shuffle.MemoryBytesSpilled += 7
+ shuffle.DiskBytesSpilled += 9
+ accumulator.add(1)
+ return TaskContext.get().stageId(), value + 1
+
+ result = self.sc.parallelize([1, 2], 1).map(increment).collect()
+ self.assertEqual([value for _, value in result], [2, 3])
+ self.assertEqual(accumulator.value, 2)
+ self.sc._jsc.sc().listenerBus().waitUntilEmpty(10000)
+ stage = self.sc._jsc.sc().statusStore().lastStageAttempt(result[0][0])
+ self.assertEqual(stage.memoryBytesSpilled(), 14)
+ self.assertEqual(stage.diskBytesSpilled(), 18)
+
def test_reuse_worker_after_take(self):
rdd = self.sc.parallelize(range(100000), 1)
self.assertEqual(0, rdd.first())
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 80ac5ab95e24..7345f45e1ceb 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -73,7 +73,7 @@ from pyspark.serializers import (
CPickleSerializer,
SpecialLengths,
write_int,
- write_long,
+ write_with_length,
)
from pyspark.sql.conversion import (
ArrowBatchTransformer,
@@ -128,12 +128,23 @@ from pyspark.worker_util import (
)
-def report_times(outfile, boot, init, finish, processing_time_ms):
- write_int(SpecialLengths.TIMING_DATA, outfile)
- write_long(int(1000 * boot), outfile)
- write_long(int(1000 * init), outfile)
- write_long(int(1000 * finish), outfile)
- write_long(processing_time_ms, outfile)
+def report_metrics(
+ outfile, boot, init, finish, execution_duration_ms, memory_bytes_spilled,
disk_bytes_spilled
+):
+ """Write the worker metrics as a length-prefixed JSON report after
METRICS_DATA."""
+ payload = json.dumps(
+ {
+ "bootTimestampMs": int(1000 * boot),
+ "initTimestampMs": int(1000 * init),
+ "finishTimestampMs": int(1000 * finish),
+ "pythonExecutionDurationMs": execution_duration_ms,
+ "memoryBytesSpilled": memory_bytes_spilled,
+ "diskBytesSpilled": disk_bytes_spilled,
+ },
+ separators=(",", ":"),
+ ).encode("utf-8")
+ write_int(SpecialLengths.METRICS_DATA, outfile)
+ write_with_length(payload, outfile)
def chain(f, g):
@@ -4542,13 +4553,13 @@ def invoke_udf(message_receiver: SparkMessageReceiver,
outfile: BinaryIO):
serializer._flush_per_batch = True
run_process = pipelined_process if is_pipelined else process
- processing_start_time = time.time()
+ execution_start_time = time.time()
with capture_outputs():
if profiler:
profiler.profile(run_process)
else:
run_process()
- processing_time_ms = int(1000 * (time.time() - processing_start_time))
+ execution_duration_ms = int(1000 * (time.time() -
execution_start_time))
# Cleanup
# Reset task context to None. This is a guard code to avoid residual
context when worker
@@ -4559,15 +4570,21 @@ def invoke_udf(message_receiver: SparkMessageReceiver,
outfile: BinaryIO):
handle_worker_exception(e, outfile)
sys.exit(-1)
finish_time = time.time()
- report_times(outfile, boot_time, init_time, finish_time,
processing_time_ms)
- write_long(shuffle.MemoryBytesSpilled, outfile)
- write_long(shuffle.DiskBytesSpilled, outfile)
+ report_metrics(
+ outfile,
+ boot_time,
+ init_time,
+ finish_time,
+ execution_duration_ms,
+ shuffle.MemoryBytesSpilled,
+ shuffle.DiskBytesSpilled,
+ )
# Mark the beginning of the accumulators section of the output
write_int(SpecialLengths.END_OF_DATA_SECTION, outfile)
send_accumulator_updates(outfile)
- # Check end of stream — raises if the finish signal is not received
correctly.
+ # Check end of stream - raises if the finish signal is not received
correctly.
# Note: this call might fail due to other reasons (e.g. channel broke)
# which will terminate the worker process.
try:
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
index c66e16f2f04c..1cf2664f67b3 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
@@ -91,12 +91,12 @@ private[python] trait PythonArrowOutput[OUT <: AnyRef] {
self: BasePythonRunner[
super.handleEndOfDataSection()
}
- protected override def handleTimingData(): Unit = {
+ protected override def handleMetricsData(): Unit = {
// Get data size from pythonMetrics which is already being tracked
totalDataReceived = pythonMetrics.get("pythonDataReceived")
.map(_.value)
.getOrElse(0L)
- super.handleTimingData()
+ super.handleMetricsData()
}
protected override def read(): OUT = {
@@ -135,8 +135,8 @@ private[python] trait PythonArrowOutput[OUT <: AnyRef] {
self: BasePythonRunner[
}
read()
- case SpecialLengths.TIMING_DATA =>
- handleTimingData()
+ case SpecialLengths.METRICS_DATA =>
+ handleMetricsData()
read()
case SpecialLengths.PYTHON_EXCEPTION_THROWN =>
throw handlePythonException()
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
index 654fec065820..b66057a50ecc 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
@@ -129,8 +129,8 @@ abstract class BasePythonUDFRunner(
batchesProcessed += 1
totalDataReceived += length
obj
- case SpecialLengths.TIMING_DATA =>
- handleTimingData()
+ case SpecialLengths.METRICS_DATA =>
+ handleMetricsData()
read()
case SpecialLengths.PYTHON_EXCEPTION_THROWN =>
throw handlePythonException()
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]