Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
19a4e03
[SPARK-59993][PYTHON] Collect scalar pandas UDF phase timing metrics
Spenserrrr Oct 6, 2026
420fb44
[SPARK-59993][PYTHON] Fix worker metrics test imports in CI
Spenserrrr Oct 6, 2026
5d5e076
[SPARK-59993][PYTHON] Clarify worker metric collector API names
Spenserrrr Oct 6, 2026
4ab7663
[SPARK-59993][PYTHON] Let handlers own task metrics and unify worker …
Spenserrrr Oct 6, 2026
6e022b6
[SPARK-59993][PYTHON] Break down scalar pandas worker processing time
Spenserrrr Oct 6, 2026
048e69d
[SPARK-59993][PYTHON] Isolate shared worker metric collection
Spenserrrr Oct 7, 2026
ceaf908
[SPARK-59993][PYTHON] Add Arrow scalar UDF phase timing
Spenserrrr Oct 7, 2026
ea2f562
[SPARK-59993][PYTHON] Report pipelined Python worker tasks
Spenserrrr Oct 7, 2026
996cb4a
[SPARK-59993][PYTHON] Reduce Arrow UDF worker timer overhead
Spenserrrr Oct 9, 2026
d624f70
[SPARK-59993][PYTHON] Retrigger CI
Spenserrrr Oct 12, 2026
44ca60f
[SPARK-59993][PYTHON] Retrigger CI after enabling fork Actions
Spenserrrr Oct 12, 2026
cd02551
[SPARK-59993][PYTHON] Split Arrow UDF stream and IPC timing
Spenserrrr Oct 12, 2026
770f7fc
[SPARK-59993][PYTHON] Gate detailed Python UDF timing metrics
Spenserrrr Oct 13, 2026
1a0ee57
[SPARK-59993][PYTHON] Time raw Arrow worker I/O beneath buffers
Spenserrrr Oct 19, 2026
efa783b
[SPARK-59993][PYTHON] Move Arrow UDF timing into eval handler
Spenserrrr Oct 19, 2026
3e3bda0
[SPARK-59993][PYTHON] Reduce timed batch counter overhead
Spenserrrr Oct 19, 2026
95ee0fb
[SPARK-59993][PYTHON] Simplify Arrow UDF timing to five phases
Spenserrrr Oct 20, 2026
f3dd02a
[SPARK-59993][PYTHON] Assert the Arrow UDF execution phase
Spenserrrr Oct 20, 2026
70774e7
[SPARK-59993][PYTHON] Strengthen Arrow UDF timing tests
Spenserrrr Oct 20, 2026
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
69 changes: 33 additions & 36 deletions core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -477,21 +477,12 @@ 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 = {
/** Decode every METRICS_DATA field as a named int64; reject other values. */
private[python] def readWorkerMetrics(stream: DataInputStream): Map[String, Long] = {
Comment thread
Spenserrrr marked this conversation as resolved.
Resolved
val length = stream.readInt()
if (length <= 0) {
throw new SparkException(s"Invalid Python worker report length: $length")
Expand All @@ -507,21 +498,13 @@ private[spark] object BasePythonRunner extends Logging {
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")
report.fields().asScala.map { entry =>
val value = entry.getValue
if (!value.isIntegralNumber || !value.canConvertToLong) {
throw new SparkException(s"Invalid Python worker metric: ${entry.getKey}")
}
value.longValue()
}

WorkerMetrics(
metricValue("bootTimestampMs"),
metricValue("initTimestampMs"),
metricValue("finishTimestampMs"),
metricValue("pythonExecutionDurationMs"),
metricValue("memoryBytesSpilled"),
metricValue("diskBytesSpilled"))
entry.getKey -> value.longValue()
}.toMap
}

/**
Expand Down Expand Up @@ -1155,17 +1138,32 @@ private[spark] abstract class BasePythonRunner[IN, OUT](

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
def requiredMetric(name: String): Long = workerMetrics.getOrElse(name,
throw new SparkException(s"Missing Python worker metric: $name"))

// Read required inputs before updating any SQL or task metrics.
val bootTime = requiredMetric("bootTimestampMs")
val initTime = requiredMetric("initTimestampMs")
val finishTime = requiredMetric("finishTimestampMs")
val pythonExecutionDurationMs = requiredMetric("pythonExecutionDurationMs")
val memoryBytesSpilled = requiredMetric("memoryBytesSpilled")
val diskBytesSpilled = requiredMetric("diskBytesSpilled")

// 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)
val boot = pythonWorkerInitializationStartTime - startTime
val init = initTime - pythonWorkerInitializationStartTime
val finish = finishTime - initTime
val total = finishTime - startTime
val finish = finishTime - initTime
val calculatedMetrics = Map(
"pythonBootTime" -> boot,
"pythonInitTime" -> init,
"pythonTotalTime" -> total,
"pythonProcessingTime" -> pythonExecutionDurationMs)
// Select registered report values; calculated timings take precedence over raw aliases.
val sqlMetrics = workerMetrics.filter { case (name, _) => metrics.contains(name) } ++
calculatedMetrics

// Format data size for readability
val dataKB = totalDataReceived / 1024.0
Expand All @@ -1181,12 +1179,11 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
log"finish = ${MDC(TIME, finish)} - " +
log"Batches: ${MDC(COUNT, batchesProcessed)}, Data: ${MDC(SIZE, dataStr)} - " +
log"${MDC(TASK_NAME, taskIdentifier(context))}")
metrics.get("pythonBootTime").foreach(_.add(boot))
metrics.get("pythonInitTime").foreach(_.add(init))
metrics.get("pythonTotalTime").foreach(_.add(total))
metrics.get("pythonProcessingTime").foreach(_.add(pythonExecutionDurationMs))
context.taskMetrics().incMemoryBytesSpilled(workerMetrics.memoryBytesSpilled)
context.taskMetrics().incDiskBytesSpilled(workerMetrics.diskBytesSpilled)
sqlMetrics.foreach { case (name, value) =>
metrics.get(name).foreach(_.add(value))
}
context.taskMetrics().incMemoryBytesSpilled(memoryBytesSpilled)
context.taskMetrics().incDiskBytesSpilled(diskBytesSpilled)
}

protected def handlePythonException(): PythonException = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,13 @@ class BasePythonRunnerSuite extends SparkFunSuite with SharedSparkContext {
"{\"bootTimestampMs\":1250,\"initTimestampMs\":2500," +
"\"finishTimestampMs\":3750,\"pythonExecutionDurationMs\":42," +
"\"memoryBytesSpilled\":7,\"diskBytesSpilled\":9}"
private val expectedMetrics = BasePythonRunner.WorkerMetrics(1250L, 2500L, 3750L, 42L, 7L, 9L)
private val expectedMetrics = Map(
"bootTimestampMs" -> 1250L,
"initTimestampMs" -> 2500L,
"finishTimestampMs" -> 3750L,
"pythonExecutionDurationMs" -> 42L,
"memoryBytesSpilled" -> 7L,
"diskBytesSpilled" -> 9L)

private def framedMetricsStream(json: String, declaredLength: Option[Int] = None,
endMarkers: Boolean = true): DataInputStream = {
Expand All @@ -745,41 +751,32 @@ class BasePythonRunnerSuite extends SparkFunSuite with SharedSparkContext {
}

test("SPARK-59773: read worker metrics and leave the following sections aligned") {
val withExtra = metricsReport.dropRight(1) + ",\"futureMetric\":{\"value\":[1,2]}}"
val withExtra = metricsReport.dropRight(1) + ",\"futureMetric\":17,\"zeroMetric\":0}"
val stream = framedMetricsStream(withExtra)
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
val result = BasePythonRunner.readWorkerMetrics(stream)
assert(result == expectedMetrics ++ Map("futureMetric" -> 17L, "zeroMetric" -> 0L))
checkEndMarkers(stream)
}

test("SPARK-59773: worker metrics require all current fields") {
Comment thread
Spenserrrr marked this conversation as resolved.
Resolved
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-59993: decode worker metric names independently of lifecycle and SQL consumers") {
val stream = framedMetricsStream("{\"futureDuration\":42}")
val result = BasePythonRunner.readWorkerMetrics(stream)
assert(result == Map("futureDuration" -> 42L))
checkEndMarkers(stream)
}

test("SPARK-59773: worker metrics reject non-int64 values") {
test("SPARK-59773: worker metric decoder rejects non-int64 values") {
val invalidValues = Seq("null", "true", "\"42\"", "42.0", "[]", "{}",
"9223372036854775808", "-9223372036854775809")
val reportWithExtra = metricsReport.dropRight(1) + ",\"futureMetric\":17}"
val fields = Seq("pythonExecutionDurationMs" -> 42,
"memoryBytesSpilled" -> 7, "diskBytesSpilled" -> 9)
"memoryBytesSpilled" -> 7, "diskBytesSpilled" -> 9, "futureMetric" -> 17)
for {
(name, originalValue) <- fields
value <- invalidValues
} {
val field = "\"" + name + "\":"
val json = metricsReport.replace(field + originalValue, field + value)
val json = reportWithExtra.replace(field + originalValue, field + value)
val stream = framedMetricsStream(json)
val error = intercept[SparkException] {
BasePythonRunner.readWorkerMetrics(stream)
Expand All @@ -797,8 +794,8 @@ class BasePythonRunnerSuite extends SparkFunSuite with SharedSparkContext {
.replace("\"diskBytesSpilled\":9", "\"diskBytesSpilled\":" + value)
val stream = framedMetricsStream(json)
assert(BasePythonRunner.readWorkerMetrics(stream) ==
expectedMetrics.copy(pythonExecutionDurationMs = value,
memoryBytesSpilled = value, diskBytesSpilled = value))
expectedMetrics ++ Map("pythonExecutionDurationMs" -> value,
"memoryBytesSpilled" -> value, "diskBytesSpilled" -> value))
checkEndMarkers(stream)
}
}
Expand Down Expand Up @@ -830,9 +827,10 @@ class BasePythonRunnerSuite extends SparkFunSuite with SharedSparkContext {
}

test("SPARK-59773: worker metrics accept a larger report with an additional field") {
val json = metricsReport.dropRight(1) + ",\"future\":\"" + ("x" * (70 * 1024)) + "\"}"
val json = metricsReport.dropRight(1) + ",\"futureMetric\":17" + (" " * (70 * 1024)) + "}"
val stream = framedMetricsStream(json)
assert(BasePythonRunner.readWorkerMetrics(stream) == expectedMetrics)
val result = BasePythonRunner.readWorkerMetrics(stream)
assert(result == expectedMetrics + ("futureMetric" -> 17L))
checkEndMarkers(stream)
}
}
133 changes: 95 additions & 38 deletions python/pyspark/eval_handlers/_arrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from __future__ import annotations

import itertools
import time
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -56,10 +57,12 @@
ArrowTableToRowsConversion,
LocalDataToArrowConversion,
)
from pyspark.sql.pandas.serializers import ArrowStreamSerializer
from pyspark.sql.pandas.types import to_arrow_schema, to_arrow_type
from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
from pyspark.sql.types import StructField, StructType
from pyspark.util import PythonEvalType, fail_on_stopiteration
from pyspark.worker_metrics import WorkerMetrics

if TYPE_CHECKING:
import pyarrow as pa
Expand Down Expand Up @@ -855,6 +858,7 @@ def __init__(
),
)
)
assert self._udf_infos, "At least one Arrow UDF expected here."
self._col_names = [f"_{i}" for i in range(len(udfs))]

# --- Input preparation ---
Expand All @@ -865,10 +869,28 @@ def __init__(
for f in eval_conf.input_type # type: ignore[union-attr]
]

@property
def serializer(self) -> ArrowStreamSerializer:
return ArrowStreamSerializer(
write_start_stream=True,
collect_timing=self._runner_conf.detailed_timing_enabled,
)

def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa

runner_conf = self._runner_conf
metrics = WorkerMetrics()
if runner_conf.detailed_timing_enabled:
phase_timers = (
metrics.measure("pythonInputPreparationTime"),
metrics.measure("pythonUDFExecutionTime"),
metrics.measure("pythonOutputPreparationTime"),
)
metrics.set("pythonNumTimingReports", 1)
metrics.set("pythonNumTimedBatches", 0)
else:
phase_timers = None

@fail_on_stopiteration
def _evaluate_batch_udf(udf_func: Any, rows: list[tuple]) -> list[Any]:
Expand All @@ -879,42 +901,77 @@ def _evaluate_batch_udf(udf_func: Any, rows: list[tuple]) -> list[Any]:
with ThreadPoolExecutor(max_workers=runner_conf.arrow_concurrency_level) as pool:
return list(pool.map(lambda row: udf_func(*row), rows))

for input_batch in data:
num_rows = input_batch.num_rows

# --- Input: Arrow -> Python columns ---
columns = [
(
[conv(v) for v in ArrowTableToRowsConversion._to_pylist(col)]
if conv is not None
else ArrowTableToRowsConversion._to_pylist(col)
)
for col, conv in zip(input_batch.itercolumns(), self._arrow_to_py_converters)
]
if not columns:
columns = [[_NoValue] * num_rows]

# --- Process: evaluate each UDF row-by-row ---
output_arrays = []
for udf_func, offsets, zero_arg, arrow_return_type, result_conv in self._udf_infos:
rows = (
[() for _ in range(num_rows)]
if zero_arg
else list(zip(*[columns[o] for o in offsets]))
)
results = _evaluate_batch_udf(udf_func, rows)
verify_result_row_count(len(results), num_rows)

# --- Output: Python -> Arrow ---
converted = (
[result_conv(r) for r in results] if result_conv is not None else results
)
try:
arr = pa.array(converted, type=arrow_return_type)
except pa.lib.ArrowInvalid:
arr = pa.array(converted).cast(
target_type=arrow_return_type, safe=runner_conf.safecheck
# Keep phase totals and the batch count local, then publish them once per task.
input_duration_ns = 0
udf_duration_ns = 0
output_duration_ns = 0
num_timed_batches = 0
clock = time.perf_counter_ns
num_udfs = len(self._udf_infos)
try:
for input_batch in data:
if phase_timers is not None:
num_timed_batches += 1
input_start_ns = clock()
num_rows = input_batch.num_rows

# --- Input: Arrow -> Python columns ---
columns = [
(
[conv(v) for v in ArrowTableToRowsConversion._to_pylist(col)]
if conv is not None
else ArrowTableToRowsConversion._to_pylist(col)
)
output_arrays.append(arr)

yield pa.RecordBatch.from_arrays(output_arrays, self._col_names)
for col, conv in zip(input_batch.itercolumns(), self._arrow_to_py_converters)
]
if not columns:
columns = [[_NoValue] * num_rows]

# --- Process: evaluate each UDF row-by-row ---
output_arrays: list[pa.Array] = []
for udf_func, offsets, zero_arg, arrow_return_type, result_conv in self._udf_infos:
if phase_timers is not None and output_arrays:
# Reuse the batch start for the first UDF, avoiding another clock read.
# Later UDFs start input after the loop transition.
input_start_ns = clock()
rows = (
[() for _ in range(num_rows)]
if zero_arg
else list(zip(*[columns[o] for o in offsets]))
)
if phase_timers is not None:
udf_start_ns = clock()
input_duration_ns += udf_start_ns - input_start_ns
results = _evaluate_batch_udf(udf_func, rows)
if phase_timers is not None:
output_start_ns = clock()
udf_duration_ns += output_start_ns - udf_start_ns
verify_result_row_count(len(results), num_rows)

# --- Output: Python -> Arrow ---
converted = (
[result_conv(r) for r in results] if result_conv is not None else results
)
try:
arr = pa.array(converted, type=arrow_return_type)
except pa.lib.ArrowInvalid:
arr = pa.array(converted).cast(
target_type=arrow_return_type, safe=runner_conf.safecheck
)
output_arrays.append(arr)
# All UDF results for this batch are ready; time its assembly as output.
if len(output_arrays) == num_udfs:
output_batch = pa.RecordBatch.from_arrays(output_arrays, self._col_names)
if phase_timers is not None:
output_duration_ns += clock() - output_start_ns

yield output_batch
del output_batch
finally:
# Publish the count and completed intervals if the generator closes early or raises.
if phase_timers is not None:
metrics.set("pythonNumTimedBatches", num_timed_batches)
input_timer, udf_timer, output_timer = phase_timers
input_timer.add_duration_ns(input_duration_ns)
udf_timer.add_duration_ns(udf_duration_ns)
output_timer.add_duration_ns(output_duration_ns)
Loading