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 d2cc47012a72 [SPARK-57361][PYTHON] Refactor SQL_ARROW_UDTF
d2cc47012a72 is described below

commit d2cc47012a72c81822627dba1095c2d9e4a0462f
Author: Yicong Huang <[email protected]>
AuthorDate: Thu Jun 11 18:05:20 2026 +0000

    [SPARK-57361][PYTHON] Refactor SQL_ARROW_UDTF
    
    ### What changes were proposed in this pull request?
    
    This PR refactors `SQL_ARROW_UDTF` so that the worker uses the plain 
`ArrowStreamSerializer` for pure Arrow stream I/O, moving the per-batch 
transformation logic from `ArrowStreamArrowUDTFSerializer` into `read_udtf()` 
in `worker.py`:
    
    - The input-side flattening (struct columns at `table_arg_offsets` are 
flattened into `pa.RecordBatch`, other columns are passed as `pa.Array`) moves 
from `ArrowStreamArrowUDTFSerializer.load_stream` into the `func` execution 
block (same style as the refactored eval types in `read_udfs()`).
    - The output-side type coercion (`ArrowBatchTransformer.enforce_schema` 
against the Arrow return type) and struct wrapping 
(`ArrowBatchTransformer.wrap_struct`) move 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.
    
    `ArrowStreamArrowUDTFSerializer` 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.arrow.test_arrow_udtf`, 
`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 9 scenario/UDTF combinations.
    
    ASV benchmark comparison (`bench_eval_type.ArrowUDTFTimeBench`, `-a 
repeat=3`, 3 runs per side, averaged). before = `upstream/master`, after = this 
PR.
    
    ```text
    scenario            udtf             before    after     diff
    ------------------  --------------  -------  -------  -------
    sm_batch_few_col    identity_udtf    1.37ms   1.35ms    -1.5%
    sm_batch_few_col    filter_udtf      1.81ms   1.84ms    +1.7%
    sm_batch_few_col    count_udtf       1.07ms   1.05ms    -2.2%
    sm_batch_many_col   identity_udtf    2.42ms   2.33ms    -3.9%
    sm_batch_many_col   filter_udtf      3.50ms   3.43ms    -2.2%
    sm_batch_many_col   count_udtf       1.14ms   1.11ms    -2.6%
    lg_batch_few_col    identity_udtf    2.00ms   2.21ms   +10.7%
    lg_batch_few_col    filter_udtf      3.33ms   3.40ms    +2.2%
    lg_batch_few_col    count_udtf       1.20ms   1.20ms    +0.3%
    lg_batch_many_col   identity_udtf    9.72ms   9.85ms    +1.4%
    lg_batch_many_col   filter_udtf     15.57ms  15.70ms    +0.9%
    lg_batch_many_col   count_udtf       3.54ms   3.49ms    -1.5%
    pure_ints           identity_udtf    3.20ms   3.19ms    -0.2%
    pure_ints           filter_udtf      4.77ms   4.38ms    -8.1%
    pure_ints           count_udtf       1.90ms   1.85ms    -2.6%
    pure_strings        identity_udtf    5.16ms   4.79ms    -7.2%
    pure_strings        filter_udtf      9.15ms   9.02ms    -1.4%
    pure_strings        count_udtf       2.28ms   2.25ms    -1.2%
    ```
    
    The `lg_batch_few_col / identity_udtf` cell is a noise artifact: its 
per-run confidence intervals overlap (both sides spike to ~2.3ms intermittently 
on this machine), and re-running it in isolation with min-of-30 direct timing 
shows the refactored path is faster (min 2.34ms vs 2.59ms, median 2.36ms vs 
2.76ms).
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    No.
    
    Closes #56440 from Yicong-Huang/refactor/arrow-udtf.
    
    Authored-by: Yicong Huang <[email protected]>
    Signed-off-by: Yicong-Huang <[email protected]>
    (cherry picked from commit be299a1efd4e30ddbee15e874bb6cfd2ff95f179)
    Signed-off-by: Yicong-Huang <[email protected]>
---
 python/pyspark/worker.py | 174 +++++++++++++++++++++++------------------------
 1 file changed, 86 insertions(+), 88 deletions(-)

diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 47a80173f86e..74319b08927a 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -87,7 +87,6 @@ from pyspark.sql.pandas.serializers import (
     TransformWithStateInPySparkRowInitStateSerializer,
     ArrowStreamAggPandasUDFSerializer,
     ArrowStreamUDTFSerializer,
-    ArrowStreamArrowUDTFSerializer,
 )
 from pyspark.sql.pandas.types import to_arrow_schema, to_arrow_type
 from pyspark.sql.types import (
@@ -1050,9 +1049,9 @@ def read_udtf(pickleSer, udtf_info, eval_type, 
runner_conf, eval_conf):
         else:
             ser = ArrowStreamUDTFSerializer()
     elif eval_type == PythonEvalType.SQL_ARROW_UDTF:
-        # Read the table argument offsets
-        # Use PyArrow-native serializer for Arrow UDTFs with potential UDT 
support
-        ser = 
ArrowStreamArrowUDTFSerializer(table_arg_offsets=eval_conf.table_arg_offsets)
+        # Pure Arrow stream I/O; table-arg flattening and output coercion
+        # are handled in the mapper below.
+        ser = ArrowStreamSerializer(write_start_stream=True)
     else:
         # Each row is a group so do not batch but send one by one.
         ser = BatchedSerializer(CPickleSerializer(), 1)
@@ -1971,113 +1970,112 @@ def read_udtf(pickleSer, udtf_info, eval_type, 
runner_conf, eval_conf):
         return mapper, None, ser, ser
 
     elif eval_type == PythonEvalType.SQL_ARROW_UDTF:
+        import pyarrow as pa
 
-        def wrap_pyarrow_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)
+        target_schema = pa.schema(list(arrow_return_type))
 
-            def verify_result(result):
-                # Validate the output schema when the result has columns
-                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__,
-                        },
-                    )
+        def verify_result(result: pa.RecordBatch, method_name: str) -> 
pa.RecordBatch:
+            # Validate the output schema when the result has columns
+            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": method_name,
+                    },
+                )
+            return result
 
-                # We verify the type of the result and do type corerion
-                # in the serializer
-                return result
+        def convert_to_arrow(res: Any, method_name: str) -> 
Iterator[pa.RecordBatch]:
+            # Check whether the result of a PyArrow UDTF is iterable before 
processing
+            if res is None:
+                res = iter([])
+            elif not isinstance(res, Iterable):
+                raise PySparkRuntimeError(
+                    errorClass="UDTF_RETURN_NOT_ITERABLE",
+                    messageParameters={
+                        "type": type(res).__name__,
+                        "func": method_name,
+                    },
+                )
 
-            # 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:
+            # Handle PyArrow Tables/RecordBatches directly
+            is_empty = True
+            for item in res:
+                is_empty = False
+                if isinstance(item, pa.Table):
+                    yield from item.to_batches()
+                elif isinstance(item, pa.RecordBatch):
+                    yield item
+                else:
+                    # Arrow UDTF should only return Arrow types 
(RecordBatch/Table)
                     raise PySparkRuntimeError(
-                        errorClass="UDTF_EXEC_ERROR",
-                        messageParameters={"method_name": f.__name__, "error": 
str(e)},
+                        errorClass="UDTF_ARROW_TYPE_CONVERSION_ERROR",
+                        messageParameters={},
                     )
 
-            def check_return_value(res):
-                # Check whether the result of a PyArrow UDTF is iterable 
before processing
-                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__,
-                            },
-                        )
-                    return res
-                else:
-                    return iter([])
-
-            def convert_to_arrow(data: Iterable):
-                data_iter = check_return_value(data)
-
-                # Handle PyArrow Tables/RecordBatches directly
-                is_empty = True
-                for item in data_iter:
-                    is_empty = False
-                    if isinstance(item, pa.Table):
-                        yield from item.to_batches()
-                    elif isinstance(item, pa.RecordBatch):
-                        yield item
-                    else:
-                        # Arrow UDTF should only return Arrow types 
(RecordBatch/Table)
-                        raise PySparkRuntimeError(
-                            errorClass="UDTF_ARROW_TYPE_CONVERSION_ERROR",
-                            messageParameters={},
-                        )
+            if is_empty:
+                yield pa.RecordBatch.from_pylist([], schema=target_schema)
 
-                if is_empty:
-                    yield pa.RecordBatch.from_pylist([], 
schema=pa.schema(list(arrow_return_type)))
-
-            def evaluate(*args: pa.RecordBatch):
-                # For Arrow UDTFs, unpack the RecordBatches and pass them to 
the function
-                for batch in convert_to_arrow(func(*args)):
-                    yield verify_result(batch), arrow_return_type
+        def evaluate(method: Callable, *args: pa.RecordBatch) -> 
Iterator[pa.RecordBatch]:
+            # Wrap the exception thrown from the UDTF in a PySparkRuntimeError.
+            try:
+                res = method(*args)
+            except SkipRestOfInputTableException:
+                raise
+            except Exception as e:
+                raise PySparkRuntimeError(
+                    errorClass="UDTF_EXEC_ERROR",
+                    messageParameters={"method_name": method.__name__, 
"error": str(e)},
+                )
 
-            return evaluate
+            for batch in convert_to_arrow(res, method.__name__):
+                coerced = ArrowBatchTransformer.enforce_schema(
+                    verify_result(batch, method.__name__), target_schema, 
safecheck=True
+                )
+                yield ArrowBatchTransformer.wrap_struct(coerced)
 
-        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_pyarrow_udtf(eval_func_kwargs_support, return_type)
-
-        if hasattr(udtf, "terminate"):
-            terminate = wrap_pyarrow_udtf(getattr(udtf, "terminate"), 
return_type)
-        else:
-            terminate = None
+        terminate = getattr(udtf, "terminate", None)
+        cleanup = getattr(udtf, "cleanup", None)
 
-        cleanup = getattr(udtf, "cleanup") if hasattr(udtf, "cleanup") else 
None
+        table_arg_offsets = (
+            set(eval_conf.table_arg_offsets) if eval_conf.table_arg_offsets 
else set()
+        )
 
-        def mapper(_, it):
+        def func(split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
+            """Apply Arrow UDTF"""
             try:
-                for a in it:
+                for batch in data:
+                    # Pre-processing: for each column, flatten struct columns 
at
+                    # table_arg_offsets into RecordBatch, keep other columns 
as Array.
+                    columns = [
+                        (
+                            ArrowBatchTransformer.flatten_struct(batch, 
column_index=i)
+                            if i in table_arg_offsets
+                            else batch.column(i)
+                        )
+                        for i in range(batch.num_columns)
+                    ]
                     # For PyArrow UDTFs, pass RecordBatches directly (no row 
conversion needed)
-                    yield from eval(*[a[o] for o in args_kwargs_offsets])
+                    yield from evaluate(eval_method, *[columns[o] for o in 
args_kwargs_offsets])
                 if terminate is not None:
-                    yield from terminate()
+                    yield from evaluate(terminate)
             except SkipRestOfInputTableException:
                 if terminate is not None:
-                    yield from terminate()
+                    yield from evaluate(terminate)
             finally:
                 if cleanup is not None:
                     cleanup()
 
-        return mapper, None, ser, ser
+        return func, None, ser, ser
 
     else:
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to