This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang 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 37194b426423 [SPARK-57394][PYTHON] Refactor SQL_ARROW_TABLE_UDF
37194b426423 is described below
commit 37194b426423be5874bc733f7d8501d435e4d1eb
Author: Yicong Huang <[email protected]>
AuthorDate: Mon Jul 6 23:50:20 2026 +0000
[SPARK-57394][PYTHON] Refactor SQL_ARROW_TABLE_UDF
### What changes were proposed in this pull request?
This PR refactors `SQL_ARROW_TABLE_UDF` (arrow-optimized Python UDTF,
`udtf(useArrow=True)`) so that the worker uses the plain
`ArrowStreamSerializer` for pure Arrow stream I/O, moving the remaining
per-batch transformation logic from `ArrowStreamUDTFSerializer` into
`read_udtf()` in `worker.py`:
- The input side already received raw Arrow record batches
(`ArrowStreamUDTFSerializer.load_stream` delegates to
`ArrowStreamSerializer.load_stream`), so loading is unchanged.
- The output-side struct wrapping (`ArrowBatchTransformer.wrap_struct`)
moves from the serializer `dump_stream` chain into the `evaluate` wrapper,
which now yields ready-to-write record batches instead of `(batch,
arrow_return_type)` tuples.
The legacy pandas conversion path (`use_legacy_pandas_udtf_conversion`,
using `ArrowStreamPandasUDTFSerializer`) is unchanged.
`ArrowStreamUDTFSerializer` itself is left in place and will be removed in a
follow-up once it has no remaining usages.
### Why are the changes needed?
Part of [SPARK-55388](https://issues.apache.org/jira/browse/SPARK-55388).
Keeping serializers as pure Arrow stream I/O and concentrating
eval-type-specific logic in `worker.py` makes the per-eval-type data flow
explicit and removes serializer subclasses that exist only to carry
per-eval-type transforms.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Existing tests (`pyspark.sql.tests.test_udtf`). No behavior change:
replaying identical worker input through the old and new code paths produces
byte-identical worker output (modulo the timing section) across 24
scenario/UDTF combinations.
ASV benchmark comparison (`bench_eval_type.ArrowTableUDFTimeBench`, `-a
repeat=3`, 3 runs per side, averaged). before = `upstream/master`, after = this
PR.
```text
scenario udtf before (ms) after (ms) diff
------------------- --------------- ----------- ----------- --------
sm_batch_few_col identity_udtf 155.7ms 155.3ms -0.2%
sm_batch_few_col explode_udtf 157.3ms 164.7ms +4.7%
sm_batch_few_col filter_udtf 124.7ms 123.0ms -1.3%
sm_batch_few_col stringify_udtf 155.7ms 155.7ms +0.0%
sm_batch_many_col identity_udtf 51.8ms 51.4ms -0.8%
sm_batch_many_col explode_udtf 52.6ms 52.3ms -0.7%
sm_batch_many_col filter_udtf 44.9ms 43.1ms -4.1%
sm_batch_many_col stringify_udtf 51.7ms 51.4ms -0.5%
lg_batch_few_col identity_udtf 397.7ms 387.0ms -2.7%
lg_batch_few_col explode_udtf 393.7ms 393.3ms -0.1%
lg_batch_few_col filter_udtf 302.0ms 300.7ms -0.4%
lg_batch_few_col stringify_udtf 386.7ms 388.3ms +0.4%
lg_batch_many_col identity_udtf 207.0ms 203.7ms -1.6%
lg_batch_many_col explode_udtf 207.7ms 205.0ms -1.3%
lg_batch_many_col filter_udtf 175.3ms 169.0ms -3.6%
lg_batch_many_col stringify_udtf 206.7ms 207.0ms +0.2%
pure_ints identity_udtf 392.7ms 401.3ms +2.2%
pure_ints explode_udtf 399.0ms 399.7ms +0.2%
pure_ints filter_udtf 310.3ms 307.3ms -1.0%
pure_ints stringify_udtf 392.0ms 394.7ms +0.7%
pure_strings identity_udtf 410.7ms 419.0ms +2.0%
pure_strings explode_udtf 416.0ms 427.3ms +2.7%
pure_strings filter_udtf 329.0ms 324.3ms -1.4%
pure_strings stringify_udtf 412.0ms 409.0ms -0.7%
```
The `sm_batch_few_col / explode_udtf` cell is a noise artifact from one
noisy ASV run (a 180ms outlier; the other two runs measured 159ms/155ms, in
line with before). Re-running it in isolation with min-of-30 direct timing
shows no regression (min 153.0ms before vs 153.3ms after, median 154.6ms vs
154.9ms).
### Was this patch authored or co-authored using generative AI tooling?
No.
Closes #56458 from Yicong-Huang/refactor/arrow-table-udf.
Authored-by: Yicong Huang <[email protected]>
Signed-off-by: Yicong-Huang <[email protected]>
(cherry picked from commit 13d5f1ccd1b772a937bec93187bdc126576d64a6)
Signed-off-by: Yicong-Huang <[email protected]>
---
python/pyspark/worker.py | 249 ++++++++++++++++++++++-------------------------
1 file changed, 118 insertions(+), 131 deletions(-)
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 18cfc15f8f89..9e629138d537 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -83,7 +83,6 @@ from pyspark.sql.pandas.serializers import (
TransformWithStateInPandasInitStateSerializer,
TransformWithStateInPySparkRowSerializer,
TransformWithStateInPySparkRowInitStateSerializer,
- ArrowStreamUDTFSerializer,
)
from pyspark.sql.pandas.types import to_arrow_schema, to_arrow_type
from pyspark.sql.types import (
@@ -868,10 +867,12 @@ def read_udtf(pickleSer, udtf_info, eval_type,
runner_conf, eval_conf):
int_to_decimal_coercion_enabled=runner_conf.int_to_decimal_coercion_enabled,
)
else:
- ser = ArrowStreamUDTFSerializer()
+ # Pure Arrow stream I/O; output struct wrapping is handled in the
+ # func below.
+ ser = ArrowStreamSerializer(write_start_stream=True)
elif eval_type == PythonEvalType.SQL_ARROW_UDTF:
# Pure Arrow stream I/O; table-arg flattening and output coercion
- # are handled in the mapper below.
+ # are handled in the func below.
ser = ArrowStreamSerializer(write_start_stream=True)
else:
# Each row is a group so do not batch but send one by one.
@@ -1616,148 +1617,130 @@ def read_udtf(pickleSer, udtf_info, eval_type,
runner_conf, eval_conf):
eval_type == PythonEvalType.SQL_ARROW_TABLE_UDF
and not runner_conf.use_legacy_pandas_udtf_conversion
):
+ import pyarrow as pa
- def wrap_arrow_udtf(f, return_type):
- import pyarrow as pa
+ arrow_return_type = to_arrow_type(
+ return_type, timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types
+ )
+ return_type_size = len(return_type)
- arrow_return_type = to_arrow_type(
- return_type, timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types
- )
- return_type_size = len(return_type)
+ def verify_result(result: pa.Table, method_name: str) -> pa.Table:
+ if not isinstance(result, pa.Table):
+ raise PySparkTypeError(
+ errorClass="INVALID_ARROW_UDTF_RETURN_TYPE",
+ messageParameters={
+ "return_type": type(result).__name__,
+ "value": str(result),
+ "func": method_name,
+ },
+ )
- def verify_result(result):
- if not isinstance(result, pa.Table):
- raise PySparkTypeError(
- errorClass="INVALID_ARROW_UDTF_RETURN_TYPE",
+ # Validate the output schema when the result dataframe has either
output
+ # rows or columns. Note that we avoid using `df.empty` here
because the
+ # result dataframe may contain an empty row. For example, when a
UDTF is
+ # defined as follows: def eval(self): yield tuple().
+ if result.num_rows > 0 or result.num_columns > 0:
+ if result.num_columns != return_type_size:
+ raise PySparkRuntimeError(
+ errorClass="UDTF_RETURN_SCHEMA_MISMATCH",
messageParameters={
- "return_type": type(result).__name__,
- "value": str(result),
- "func": f.__name__,
+ "expected": str(return_type_size),
+ "actual": str(result.num_columns),
+ "func": method_name,
},
)
- # Validate the output schema when the result dataframe has
either output
- # rows or columns. Note that we avoid using `df.empty` here
because the
- # result dataframe may contain an empty row. For example, when
a UDTF is
- # defined as follows: def eval(self): yield tuple().
- if result.num_rows > 0 or result.num_columns > 0:
- if result.num_columns != return_type_size:
- raise PySparkRuntimeError(
- errorClass="UDTF_RETURN_SCHEMA_MISMATCH",
- messageParameters={
- "expected": str(return_type_size),
- "actual": str(result.num_columns),
- "func": f.__name__,
- },
- )
-
- # Verify the type and the schema of the result.
- verify_arrow_result(
- result,
- assign_cols_by_name=False,
- expected_cols_and_types=[
- (field.name, field.type) for field in arrow_return_type
- ],
- )
- return result
+ # Verify the type and the schema of the result.
+ verify_arrow_result(
+ result,
+ assign_cols_by_name=False,
+ expected_cols_and_types=[(field.name, field.type) for field in
arrow_return_type],
+ )
+ return result
- # Wrap the exception thrown from the UDTF in a PySparkRuntimeError.
- def func(*a: Any) -> Any:
- try:
- return f(*a)
- except SkipRestOfInputTableException:
- raise
- except Exception as e:
+ def check_return_value(res: Any, method_name: str) -> Iterator:
+ # Check whether the result of an arrow UDTF is iterable before
+ # using it to construct a pandas DataFrame.
+ if res is not None:
+ if not isinstance(res, Iterable):
raise PySparkRuntimeError(
- errorClass="UDTF_EXEC_ERROR",
- messageParameters={"method_name": f.__name__, "error":
str(e)},
+ errorClass="UDTF_RETURN_NOT_ITERABLE",
+ messageParameters={
+ "type": type(res).__name__,
+ "func": method_name,
+ },
)
+ for row in res:
+ if not isinstance(row, tuple) and return_type_size == 1:
+ row = (row,)
+ if check_output_row_against_schema is not None:
+ if row is not None:
+ check_output_row_against_schema(row)
+ yield row
- def check_return_value(res):
- # Check whether the result of an arrow UDTF is iterable before
- # using it to construct a pandas DataFrame.
- if res is not None:
- if not isinstance(res, Iterable):
- raise PySparkRuntimeError(
- errorClass="UDTF_RETURN_NOT_ITERABLE",
- messageParameters={
- "type": type(res).__name__,
- "func": f.__name__,
- },
- )
- for row in res:
- if not isinstance(row, tuple) and return_type_size ==
1:
- row = (row,)
- if check_output_row_against_schema is not None:
- if row is not None:
- check_output_row_against_schema(row)
- yield row
-
- def convert_to_arrow(data: Iterable):
- data = list(check_return_value(data))
- if len(data) == 0:
- # Return one empty RecordBatch to match the left side of
the lateral join
- return [
- pa.RecordBatch.from_pylist(data,
schema=pa.schema(list(arrow_return_type)))
- ]
+ def convert_rows_to_arrow(data: Iterable, method_name: str) ->
list[pa.RecordBatch]:
+ data = list(check_return_value(data, method_name))
+ if len(data) == 0:
+ # Return one empty RecordBatch to match the left side of the
lateral join
+ return [pa.RecordBatch.from_pylist(data,
schema=pa.schema(list(arrow_return_type)))]
+
+ def raise_conversion_error(original_exception):
+ raise PySparkRuntimeError(
+ errorClass="UDTF_ARROW_DATA_CONVERSION_ERROR",
+ messageParameters={
+ "data": str(data),
+ "schema": return_type.simpleString(),
+ "arrow_schema": str(arrow_return_type),
+ },
+ ) from original_exception
- def raise_conversion_error(original_exception):
+ try:
+ table = LocalDataToArrowConversion.convert(
+ data, return_type, runner_conf.use_large_var_types
+ )
+ except PySparkValueError as e:
+ if e.getErrorClass() == "AXIS_LENGTH_MISMATCH":
raise PySparkRuntimeError(
- errorClass="UDTF_ARROW_DATA_CONVERSION_ERROR",
+ errorClass="UDTF_RETURN_SCHEMA_MISMATCH",
messageParameters={
- "data": str(data),
- "schema": return_type.simpleString(),
- "arrow_schema": str(arrow_return_type),
+ "expected":
e.getMessageParameters()["expected_length"], # type: ignore[index]
+ "actual":
e.getMessageParameters()["actual_length"], # type: ignore[index]
+ "func": method_name,
},
- ) from original_exception
+ ) from e
+ # Fall through to general conversion error
+ raise_conversion_error(e)
+ except Exception as e:
+ raise_conversion_error(e)
+
+ return verify_result(table, method_name).to_batches()
+ def evaluate_rows(
+ method: Callable, *args: list, num_rows: int = 1
+ ) -> Iterator[pa.RecordBatch]:
+ rows = itertools.repeat((), num_rows) if len(args) == 0 else
zip(*args)
+ for row in rows:
+ # Wrap the exception thrown from the UDTF in a
PySparkRuntimeError.
try:
- table = LocalDataToArrowConversion.convert(
- data, return_type, runner_conf.use_large_var_types
- )
- except PySparkValueError as e:
- if e.getErrorClass() == "AXIS_LENGTH_MISMATCH":
- raise PySparkRuntimeError(
- errorClass="UDTF_RETURN_SCHEMA_MISMATCH",
- messageParameters={
- "expected":
e.getMessageParameters()["expected_length"], # type: ignore[index]
- "actual":
e.getMessageParameters()["actual_length"], # type: ignore[index]
- "func": f.__name__,
- },
- ) from e
- # Fall through to general conversion error
- raise_conversion_error(e)
+ res = method(*row)
+ except SkipRestOfInputTableException:
+ raise
except Exception as e:
- raise_conversion_error(e)
-
- return verify_result(table).to_batches()
-
- def evaluate(*args: list, num_rows=1):
- if len(args) == 0:
- for _ in range(num_rows):
- for batch in convert_to_arrow(func()):
- yield batch, arrow_return_type
-
- else:
- for row in zip(*args):
- for batch in convert_to_arrow(func(*row)):
- yield batch, arrow_return_type
-
- return evaluate
+ raise PySparkRuntimeError(
+ errorClass="UDTF_EXEC_ERROR",
+ messageParameters={"method_name": method.__name__,
"error": str(e)},
+ )
+ for batch in convert_rows_to_arrow(res, method.__name__):
+ yield ArrowBatchTransformer.wrap_struct(batch)
- eval_func_kwargs_support, args_kwargs_offsets = wrap_kwargs_support(
+ eval_method, args_kwargs_offsets = wrap_kwargs_support(
getattr(udtf, "eval"), udtf_info.args, udtf_info.kwargs
)
- eval = wrap_arrow_udtf(eval_func_kwargs_support, return_type)
-
- if hasattr(udtf, "terminate"):
- terminate = wrap_arrow_udtf(getattr(udtf, "terminate"),
return_type)
- else:
- terminate = None
-
- cleanup = getattr(udtf, "cleanup") if hasattr(udtf, "cleanup") else
None
+ terminate = getattr(udtf, "terminate", None)
+ cleanup = getattr(udtf, "cleanup", None)
- def mapper(_, it):
+ def func(split_index: int, data: Iterator[pa.RecordBatch]) ->
Iterator[pa.RecordBatch]:
+ """Apply Arrow table UDF"""
try:
converters = [
ArrowTableToRowsConversion._create_converter(
@@ -1767,28 +1750,32 @@ def read_udtf(pickleSer, udtf_info, eval_type,
runner_conf, eval_conf):
)
for f in eval_conf.input_type
]
- for a in it:
+ for batch in data:
+ # Convert each input column to a list of Python values per
row,
+ # then call eval once per input row.
pylist = [
(
[conv(v) for v in column.to_pylist()]
if conv is not None
else column.to_pylist()
)
- for column, conv in zip(a.columns, converters)
+ for column, conv in zip(batch.columns, converters)
]
- # The eval function yields an iterator. Each element
produced by this
- # iterator is a tuple in the form of (pyarrow.RecordBatch,
arrow_return_type).
- yield from eval(*[pylist[o] for o in args_kwargs_offsets],
num_rows=a.num_rows)
+ yield from evaluate_rows(
+ eval_method,
+ *[pylist[o] for o in args_kwargs_offsets],
+ num_rows=batch.num_rows,
+ )
if terminate is not None:
- yield from terminate()
+ yield from evaluate_rows(terminate)
except SkipRestOfInputTableException:
if terminate is not None:
- yield from terminate()
+ yield from evaluate_rows(terminate)
finally:
if cleanup is not None:
cleanup()
- return mapper, None, ser, ser
+ return func, None, ser, ser
elif eval_type == PythonEvalType.SQL_ARROW_UDTF:
import pyarrow as pa
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]