This is an automated email from the ASF dual-hosted git repository.
HyukjinKwon 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 15af8057fe66 [SPARK-58695][SQL][PYTHON] Support scalar pandas and
Arrow UDFs inside higher-order function lambdas
15af8057fe66 is described below
commit 15af8057fe663662a3b53ad6536b5496c416b5b6
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Tue Aug 11 12:37:42 2026 +0900
[SPARK-58695][SQL][PYTHON] Support scalar pandas and Arrow UDFs inside
higher-order function lambdas
### What changes were proposed in this pull request?
This is a follow-up to SPARK-27052, which let a plain (row-at-a-time)
Python UDF be used inside a higher-order function's lambda by lifting it out of
the lambda and applying it once to the whole array. That work handled only
`SQL_BATCHED_UDF` / `SQL_ARROW_BATCHED_UDF`, lifting them to a single
element-wise eval type `SQL_ARROW_ELEMENTWISE_UDF` (=102).
This PR extends the same rewrite to the vectorized scalar UDFs, so that e.g.
```python
pandas_udf("int")
def plus_one(s: pd.Series) -> pd.Series:
return s + 1
df.select(F.transform("values", lambda x: plus_one(x)))
```
works, instead of failing with
`[UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF]`. All four vectorized
scalar flavors are covered: scalar pandas (`pandas_udf`), scalar Arrow
(`arrow_udf`), and their iterator variants.
Each flavor lifts to its own dedicated element-wise eval type so the Python
worker keeps that flavor's native batching contract, mirroring the existing 102:
- `SQL_SCALAR_PANDAS_ELEMENTWISE_UDF` (=103)
- `SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF` (=104)
- `SQL_SCALAR_ARROW_ELEMENTWISE_UDF` (=105)
- `SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF` (=106)
`PythonUDF.liftedElementwiseEvalType` maps each base eval type to its
lifted counterpart, and `isElementwiseRewritableUDF` now accepts the vectorized
types (still excluding zero-argument, named-argument, and UDT shapes, which the
lift cannot preserve). In the Python worker, each argument arrives as
`array<T>` aligned with the iterated array; the worker flattens each list
column to its element column, runs the vectorized function once over that flat
column (a `pandas.Series` / `pyarrow [...]
### Why are the changes needed?
SPARK-27052 supported only plain Python UDFs inside HOF lambdas. Vectorized
scalar UDFs (pandas / Arrow) are the more performant and more common form, and
users expect them to compose with higher-order functions the same way.
### Does this PR introduce _any_ user-facing change?
Yes. A scalar pandas or Arrow UDF (and their iterator variants) can now be
used inside a higher-order function's lambda, e.g.
`df.select(F.transform("values", lambda x: pandas_plus_one(x)))`. Before this
change such a query failed at analysis with
`[UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF]`; now it evaluates. No
existing successful query changes behavior. This extends an unreleased feature
(SPARK-27052), so it is not a change relative to any released version.
### How was this patch tested?
- `pyspark.sql.tests.test_udf_in_higher_order_function` (classic and
Connect parity): new tests for all four vectorized flavors across `transform` /
`filter`, covering null arrays, null elements, empty arrays, and multi-batch
iterator regrouping.
- `ExtractPythonUDFFromLambdaSuite`: the previous "a pandas UDF inside a
lambda still fails" test is replaced by one asserting each vectorized flavor is
lifted to its element-wise eval type; the rewritable-predicate test now covers
all rewritable eval types and the base -> lifted mapping.
- `PythonUDFSuite` and `BatchEvalPythonExecSuite` pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
This pull request and its description were written by Isaac.
Closes #57899 from HyukjinKwon/SPARK-58695-pandas-arrow-udf-in-hof.
Authored-by: Hyukjin Kwon <[email protected]>
Signed-off-by: Hyukjin Kwon <[email protected]>
---
.../org/apache/spark/api/python/PythonRunner.scala | 22 ++
.../docs/source/tutorial/sql/arrow_python_udf.rst | 5 +
python/pyspark/sql/_typing.pyi | 4 +
.../sql/tests/test_udf_in_higher_order_function.py | 317 +++++++++++++++++-
python/pyspark/util.py | 8 +
python/pyspark/worker.py | 362 +++++++++++++++++++++
.../spark/sql/catalyst/expressions/PythonUDF.scala | 54 ++-
.../sql/execution/python/ArrowEvalPythonExec.scala | 10 +
.../sql/execution/python/ArrowPythonRunner.scala | 6 +-
.../python/ExtractPythonUDFFromLambda.scala | 20 +-
.../sql/execution/python/ExtractPythonUDFs.scala | 11 +-
.../python/BatchEvalPythonExecSuite.scala | 21 ++
.../python/ExtractPythonUDFFromLambdaSuite.scala | 41 ++-
13 files changed, 854 insertions(+), 27 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 19a482850311..d7800b1147e1 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
@@ -53,7 +53,13 @@ private[spark] object PythonEvalType {
val SQL_ARROW_BATCHED_UDF = 101
// A scalar Python UDF applied element-wise over the elements of an array
column, used to
// support Python UDFs inside higher-order function lambdas. See
ExtractPythonUDFFromLambda.
+ // 102 lifts a row-at-a-time UDF (SQL_BATCHED_UDF / SQL_ARROW_BATCHED_UDF);
103-106 lift the
+ // vectorized scalar UDFs, preserving pandas- vs. Arrow-shaped batches and
the iterator contract.
val SQL_ARROW_ELEMENTWISE_UDF = 102
+ val SQL_SCALAR_PANDAS_ELEMENTWISE_UDF = 103
+ val SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF = 104
+ val SQL_SCALAR_ARROW_ELEMENTWISE_UDF = 105
+ val SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF = 106
val SQL_SCALAR_PANDAS_UDF = 200
val SQL_GROUPED_MAP_PANDAS_UDF = 201
@@ -90,6 +96,10 @@ private[spark] object PythonEvalType {
case SQL_BATCHED_UDF => "SQL_BATCHED_UDF"
case SQL_ARROW_BATCHED_UDF => "SQL_ARROW_BATCHED_UDF"
case SQL_ARROW_ELEMENTWISE_UDF => "SQL_ARROW_ELEMENTWISE_UDF"
+ case SQL_SCALAR_PANDAS_ELEMENTWISE_UDF =>
"SQL_SCALAR_PANDAS_ELEMENTWISE_UDF"
+ case SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF =>
"SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF"
+ case SQL_SCALAR_ARROW_ELEMENTWISE_UDF => "SQL_SCALAR_ARROW_ELEMENTWISE_UDF"
+ case SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF =>
"SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF"
case SQL_SCALAR_PANDAS_UDF => "SQL_SCALAR_PANDAS_UDF"
case SQL_GROUPED_MAP_PANDAS_UDF => "SQL_GROUPED_MAP_PANDAS_UDF"
case SQL_GROUPED_AGG_PANDAS_UDF => "SQL_GROUPED_AGG_PANDAS_UDF"
@@ -121,6 +131,18 @@ private[spark] object PythonEvalType {
case SQL_WINDOW_AGG_ARROW_UDF => "SQL_WINDOW_AGG_ARROW_UDF"
case SQL_GROUPED_AGG_ARROW_ITER_UDF => "SQL_GROUPED_AGG_ARROW_ITER_UDF"
}
+
+ // The eval types produced by ExtractPythonUDFFromLambda: a scalar UDF
lifted out of a
+ // higher-order function's lambda, which receives each argument as an
`array<T>` column and is
+ // applied element-wise inside the Python worker. See
ExtractPythonUDFFromLambda.
+ def isElementwiseUDF(evalType: Int): Boolean = evalType match {
+ case SQL_ARROW_ELEMENTWISE_UDF |
+ SQL_SCALAR_PANDAS_ELEMENTWISE_UDF |
+ SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF |
+ SQL_SCALAR_ARROW_ELEMENTWISE_UDF |
+ SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF => true
+ case _ => false
+ }
}
private[spark] object BasePythonRunner extends Logging {
diff --git a/python/docs/source/tutorial/sql/arrow_python_udf.rst
b/python/docs/source/tutorial/sql/arrow_python_udf.rst
index 0a0509f05003..fb1ff7515218 100644
--- a/python/docs/source/tutorial/sql/arrow_python_udf.rst
+++ b/python/docs/source/tutorial/sql/arrow_python_udf.rst
@@ -490,6 +490,11 @@ handle those cases inside the function itself. This path
is enabled by
``spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled`` (default
``true``); set it to
``false`` to reject such queries at analysis instead.
+This works for a plain Python UDF as well as for a vectorized scalar UDF - a
scalar pandas UDF
+(``pandas_udf``), a scalar Arrow UDF (``arrow_udf``), and their iterator
variants. A vectorized
+UDF still receives its native batch (a ``pandas.Series`` / ``pyarrow.Array``,
or an iterator of
+them) over the flattened array elements, and one output value is produced per
input element.
+
The Arrow data type of the returned ``pyarrow.Array`` should match the
declared ``returnType``.
When there is a mismatch, Spark will attempt to convert the returned data to
the expected type
using Arrow's safe casting, which raises an error on overflow or precision
loss.
diff --git a/python/pyspark/sql/_typing.pyi b/python/pyspark/sql/_typing.pyi
index 95bf27662ff1..a4713370aaf7 100644
--- a/python/pyspark/sql/_typing.pyi
+++ b/python/pyspark/sql/_typing.pyi
@@ -65,6 +65,10 @@ RowLike = TypeVar("RowLike", List[Any], Tuple[Any, ...],
pyspark.sql.types.Row)
SQLBatchedUDFType = Literal[100]
SQLArrowBatchedUDFType = Literal[101]
SQLArrowElementwiseUDFType = Literal[102]
+SQLScalarPandasElementwiseUDFType = Literal[103]
+SQLScalarPandasIterElementwiseUDFType = Literal[104]
+SQLScalarArrowElementwiseUDFType = Literal[105]
+SQLScalarArrowIterElementwiseUDFType = Literal[106]
SQLTableUDFType = Literal[300]
SQLArrowTableUDFType = Literal[301]
SQLArrowUDTFType = Literal[302]
diff --git a/python/pyspark/sql/tests/test_udf_in_higher_order_function.py
b/python/pyspark/sql/tests/test_udf_in_higher_order_function.py
index c027c4779fea..9d0beb024abe 100644
--- a/python/pyspark/sql/tests/test_udf_in_higher_order_function.py
+++ b/python/pyspark/sql/tests/test_udf_in_higher_order_function.py
@@ -643,20 +643,325 @@ class UDFInHigherOrderFunctionTestsMixin:
df.select(agg).collect()
self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF",
str(ctx.exception))
- def test_pandas_udf_in_lambda_still_fails(self):
- # A vectorized (pandas) UDF in a lambda is not supported.
+ def test_scalar_pandas_udf_in_lambda(self):
+ # A vectorized scalar pandas UDF is lifted and applied over the
flattened elements, so it
+ # still receives a pandas Series (its native contract) once per batch,
not per element.
import pandas as pd
from pyspark.sql.functions import pandas_udf
- df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+ df = self.spark.createDataFrame(
+ [([1, 2, 3],), ([],), (None,), ([10, None],)], "values array<int>"
+ )
@pandas_udf(IntegerType())
def plus_one_pandas(s: pd.Series) -> pd.Series:
return s + 1
- with self.assertRaises(AnalysisException) as ctx:
- df.select(sf.transform("values", lambda x:
plus_one_pandas(x))).collect()
- self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", str(ctx.exception))
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
plus_one_pandas(x)).alias("r")),
+ [([2, 3, 4],), ([],), (None,), ([11, None],)],
+ )
+ # Arithmetic composition around the UDF result is ordinary JVM work.
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x: plus_one_pandas(x) *
2).alias("r")),
+ [([4, 6, 8],), ([],), (None,), ([22, None],)],
+ )
+
+ def test_scalar_arrow_udf_in_lambda(self):
+ # A vectorized scalar Arrow UDF is lifted the same way; it takes and
returns a pyarrow
+ # Array over the flattened elements.
+ import pyarrow as pa
+ from pyspark.sql.functions import arrow_udf
+
+ df = self.spark.createDataFrame(
+ [([1, 2, 3],), ([],), (None,), ([10, None],)], "values array<int>"
+ )
+
+ @arrow_udf(IntegerType())
+ def plus_one_arrow(a: pa.Array) -> pa.Array:
+ return pa.compute.add(a, 1)
+
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
plus_one_arrow(x)).alias("r")),
+ [([2, 3, 4],), ([],), (None,), ([11, None],)],
+ )
+ assertDataFrameEqual(
+ df.select(sf.filter("values", lambda x: plus_one_arrow(x) >
2).alias("r")),
+ [([2, 3],), ([],), (None,), ([10],)],
+ )
+
+ def test_scalar_pandas_iter_udf_in_lambda(self):
+ # A scalar iterator pandas UDF keeps its iterator contract: it
consumes and produces an
+ # iterator of Series. The worker feeds it the flattened elements and
re-groups the streamed
+ # results back into arrays positionally, so output batch boundaries
need not match input.
+ from typing import Iterator
+ import pandas as pd
+ from pyspark.sql.functions import pandas_udf
+
+ df = self.spark.createDataFrame(
+ [([1, 2, 3],), ([],), (None,), ([10, 20],), ([5],)], "values
array<int>"
+ )
+
+ @pandas_udf(IntegerType())
+ def plus_one_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+ for s in it:
+ yield s + 1
+
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
plus_one_iter(x)).alias("r")),
+ [([2, 3, 4],), ([],), (None,), ([11, 21],), ([6],)],
+ )
+
+ def test_scalar_arrow_iter_udf_in_lambda(self):
+ # A scalar iterator Arrow UDF, lifted the same way as the pandas
iterator variant.
+ from typing import Iterator
+ import pyarrow as pa
+ from pyspark.sql.functions import arrow_udf
+
+ df = self.spark.createDataFrame(
+ [([1, 2, 3],), ([],), (None,), ([10, 20],), ([5],)], "values
array<int>"
+ )
+
+ @arrow_udf(IntegerType())
+ def plus_one_arrow_iter(it: Iterator[pa.Array]) -> Iterator[pa.Array]:
+ for a in it:
+ yield pa.compute.add(a, 1)
+
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
plus_one_arrow_iter(x)).alias("r")),
+ [([2, 3, 4],), ([],), (None,), ([11, 21],), ([6],)],
+ )
+
+ def test_scalar_pandas_iter_udf_multiple_arguments_differ_in_type(self):
+ # A two-argument iterator UDF whose arguments have different element
types: the array
+ # element (int) and an outer column (string) that the rewrite repeats
into an aligned
+ # array. Each argument must be flattened with its own element type,
not the first's.
+ from typing import Iterator, Tuple
+ import pandas as pd
+ from pyspark.sql.functions import pandas_udf
+
+ df = self.spark.createDataFrame(
+ [([1, 2, 3], "a"), ([], "b"), (None, "c"), ([10], "d")],
+ "values array<int>, tag string",
+ )
+
+ @pandas_udf(StringType())
+ def tag_each(it: Iterator[Tuple[pd.Series, pd.Series]]) ->
Iterator[pd.Series]:
+ for x, t in it:
+ yield t + x.astype("string")
+
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x: tag_each(x,
sf.col("tag"))).alias("r")),
+ [(["a1", "a2", "a3"],), ([],), (None,), (["d10"],)],
+ )
+
+ def test_scalar_pandas_iter_udf_timestamp_return_type(self):
+ # A timestamp-returning pandas iterator UDF with a non-UTC session
timezone: the result
+ # chunks are typed with the session timezone, so the streamed buffer
must take its type
+ # from the first chunk rather than assuming UTC, or pa.concat_arrays
would fail. Assert both
+ # against the equivalent non-iterator pandas UDF (isolates the concat
fix) and against a
+ # native Spark expression computing the same instants (so a timezone
bug common to both UDF
+ # paths would still be caught, while going through identical
driver-collection semantics).
+ from typing import Iterator
+ import pandas as pd
+ from pyspark.sql.functions import pandas_udf
+ from pyspark.sql.types import TimestampType
+
+ with self.sql_conf({"spark.sql.session.timeZone":
"America/Los_Angeles"}):
+ df = self.spark.createDataFrame([([1, 2],), (None,), ([3],)],
"values array<int>")
+
+ def compute(x):
+ return pd.to_datetime(x, unit="D", origin="2020-01-01")
+
+ @pandas_udf(TimestampType())
+ def to_ts(s: pd.Series) -> pd.Series:
+ return compute(s)
+
+ @pandas_udf(TimestampType())
+ def to_ts_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+ for s in it:
+ yield compute(s)
+
+ iter_df = df.select(sf.transform("values", lambda x:
to_ts_iter(x)).alias("r"))
+ # Native equivalent: pandas interprets the tz-naive origin in the
session timezone, so
+ # `timestamp_add(DAY, x, TIMESTAMP '2020-01-01 00:00:00')` (also
session-local) matches.
+ native_df = df.select(
+ sf.transform(
+ "values",
+ lambda x: sf.timestamp_add("DAY", x,
sf.lit("2020-01-01").cast("timestamp")),
+ ).alias("r")
+ )
+ # Consistent with the non-iterator pandas UDF, and with the native
instants.
+ assertDataFrameEqual(
+ iter_df,
+ df.select(sf.transform("values", lambda x:
to_ts(x)).alias("r")),
+ )
+ assertDataFrameEqual(iter_df, native_df)
+
+ def test_scalar_iter_udf_over_all_empty_and_null_partition(self):
+ # SPARK-58695: when a whole partition holds only empty/null arrays,
the flattened inputs are
+ # all zero-length and a skip-empty iterator UDF yields no chunks.
Those rows still need one
+ # (empty / null) output row each, or the positional JVM join drops
them silently. Cover both
+ # the pandas and Arrow iterator flavors.
+ from typing import Iterator
+ import pandas as pd
+ import pyarrow as pa
+ from pyspark.sql.functions import pandas_udf, arrow_udf
+
+ # Single partition so the whole batch is empty/null arrays.
+ df = self.spark.createDataFrame(
+ [([],), (None,), ([],), (None,)], "values array<int>"
+ ).coalesce(1)
+
+ @pandas_udf(IntegerType())
+ def skip_empty_pandas(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+ for s in it:
+ if len(s) == 0:
+ continue
+ yield s + 1
+
+ @arrow_udf(IntegerType())
+ def skip_empty_arrow(it: Iterator[pa.Array]) -> Iterator[pa.Array]:
+ for a in it:
+ if len(a) == 0:
+ continue
+ yield pa.compute.add(a, 1)
+
+ for f in (skip_empty_pandas, skip_empty_arrow):
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x: f(x)).alias("r")),
+ [([],), (None,), ([],), (None,)],
+ )
+
+ def test_scalar_pandas_iter_udf_timestamp_after_empty_batch(self):
+ # A zero-length result chunk (from an input batch holding only
empty/null arrays) must not
+ # pin the output stream's timestamp type to the UTC-typed default: the
rows it emits and
+ # the rows emitted from a later real chunk (typed with the session
timezone) would then
+ # disagree, and the Arrow stream writer would reject the second output
batch. Assert against
+ # the equivalent non-iterator pandas UDF (identical driver-collection
semantics), so the
+ # check proves the schema fix without depending on absolute timezone
offsets.
+ from typing import Iterator
+ import pandas as pd
+ from pyspark.sql.functions import pandas_udf
+ from pyspark.sql.types import TimestampType
+
+ with self.sql_conf(
+ {
+ "spark.sql.session.timeZone": "America/Los_Angeles",
+ # One row per Arrow batch so the empty-array row forms its own
(first) batch.
+ "spark.sql.execution.arrow.maxRecordsPerBatch": "1",
+ }
+ ):
+ df = self.spark.createDataFrame([([],), ([1],)], "values
array<int>").coalesce(1)
+
+ def compute(x):
+ return pd.to_datetime(x, unit="D", origin="2020-01-01")
+
+ @pandas_udf(TimestampType())
+ def to_ts(s: pd.Series) -> pd.Series:
+ return compute(s)
+
+ @pandas_udf(TimestampType())
+ def to_ts_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+ for s in it:
+ yield compute(s)
+
+ # Without the fix this raises ArrowInvalid ("different schema")
writing the second
+ # output batch; with it the iterator result matches the
non-iterator pandas UDF.
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
to_ts_iter(x)).alias("r")),
+ df.select(sf.transform("values", lambda x:
to_ts(x)).alias("r")),
+ )
+
+ def test_scalar_pandas_udf_struct_element_return_type(self):
+ # A vectorized pandas UDF returning a struct element (a
pandas.DataFrame per batch) inside a
+ # lambda. Covers the struct-DataFrame result path of the element-wise
pandas branch.
+ import pandas as pd
+ from pyspark.sql.functions import pandas_udf
+ from pyspark.sql.types import StructType, StructField
+
+ df = self.spark.createDataFrame([([1, 2, 3],), ([],), (None,)],
"values array<int>")
+ ret = StructType([StructField("v", IntegerType()), StructField("neg",
IntegerType())])
+
+ @pandas_udf(ret)
+ def to_struct(s: pd.Series) -> pd.DataFrame:
+ return pd.DataFrame({"v": s, "neg": -s})
+
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
to_struct(x)).alias("r")),
+ [
+ ([(1, -1), (2, -2), (3, -3)],),
+ ([],),
+ (None,),
+ ],
+ )
+
+ def test_chained_vectorized_udfs_in_lambda(self):
+ # Nested calls f(g(x)) inside a lambda: g is lifted first, then f
consumes g's array result.
+ # Cover both the non-iterator and iterator vectorized flavors.
+ from typing import Iterator
+ import pandas as pd
+ from pyspark.sql.functions import pandas_udf
+
+ df = self.spark.createDataFrame(
+ [([1, 2, 3],), ([],), (None,), ([10],)], "values array<int>"
+ )
+
+ @pandas_udf(IntegerType())
+ def plus_one(s: pd.Series) -> pd.Series:
+ return s + 1
+
+ @pandas_udf(IntegerType())
+ def times_two(s: pd.Series) -> pd.Series:
+ return s * 2
+
+ @pandas_udf(IntegerType())
+ def plus_one_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+ for s in it:
+ yield s + 1
+
+ @pandas_udf(IntegerType())
+ def times_two_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+ for s in it:
+ yield s * 2
+
+ # (x + 1) * 2, verified against the equivalent native expression.
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
times_two(plus_one(x))).alias("r")),
+ df.select(sf.transform("values", lambda x: (x + 1) *
2).alias("r")),
+ )
+ assertDataFrameEqual(
+ df.select(
+ sf.transform("values", lambda x:
times_two_iter(plus_one_iter(x))).alias("r")
+ ),
+ df.select(sf.transform("values", lambda x: (x + 1) *
2).alias("r")),
+ )
+
+ def test_scalar_iter_udf_struct_element_return_type(self):
+ # A scalar iterator pandas UDF returning a struct element (a
pandas.DataFrame per batch)
+ # inside a lambda. Covers the struct-DataFrame result path of the
iterator element-wise
+ # branch (Iterator[pd.DataFrame] contract).
+ from typing import Iterator
+ import pandas as pd
+ from pyspark.sql.functions import pandas_udf
+ from pyspark.sql.types import StructType, StructField
+
+ df = self.spark.createDataFrame([([1, 2, 3],), ([],), (None,)],
"values array<int>")
+ ret = StructType([StructField("v", IntegerType()), StructField("neg",
IntegerType())])
+
+ @pandas_udf(ret)
+ def to_struct_iter(it: Iterator[pd.Series]) -> Iterator[pd.DataFrame]:
+ for s in it:
+ yield pd.DataFrame({"v": s, "neg": -s})
+
+ assertDataFrameEqual(
+ df.select(sf.transform("values", lambda x:
to_struct_iter(x)).alias("r")),
+ [
+ ([(1, -1), (2, -2), (3, -3)],),
+ ([],),
+ (None,),
+ ],
+ )
def test_non_arrow_udf_is_also_supported(self):
# A UDF created with useArrow=False is still rewritable; the generated
array wrapper
diff --git a/python/pyspark/util.py b/python/pyspark/util.py
index 640728725ee3..76be9bcc9998 100644
--- a/python/pyspark/util.py
+++ b/python/pyspark/util.py
@@ -91,6 +91,10 @@ if typing.TYPE_CHECKING:
from pyspark.sql._typing import (
SQLArrowBatchedUDFType,
SQLArrowElementwiseUDFType,
+ SQLScalarPandasElementwiseUDFType,
+ SQLScalarPandasIterElementwiseUDFType,
+ SQLScalarArrowElementwiseUDFType,
+ SQLScalarArrowIterElementwiseUDFType,
SQLArrowTableUDFType,
SQLBatchedUDFType,
SQLTableUDFType,
@@ -664,6 +668,10 @@ class PythonEvalType:
SQL_BATCHED_UDF: "SQLBatchedUDFType" = 100
SQL_ARROW_BATCHED_UDF: "SQLArrowBatchedUDFType" = 101
SQL_ARROW_ELEMENTWISE_UDF: "SQLArrowElementwiseUDFType" = 102
+ SQL_SCALAR_PANDAS_ELEMENTWISE_UDF: "SQLScalarPandasElementwiseUDFType" =
103
+ SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF:
"SQLScalarPandasIterElementwiseUDFType" = 104
+ SQL_SCALAR_ARROW_ELEMENTWISE_UDF: "SQLScalarArrowElementwiseUDFType" = 105
+ SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF:
"SQLScalarArrowIterElementwiseUDFType" = 106
SQL_SCALAR_PANDAS_UDF: "PandasScalarUDFType" = 200
SQL_GROUPED_MAP_PANDAS_UDF: "PandasGroupedMapUDFType" = 201
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index c579c719c6e2..4e77c630777d 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -498,6 +498,11 @@ def _is_iter_based(eval_type: int) -> bool:
return eval_type in (
PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF,
+ # Iterator UDFs lifted out of a higher-order function lambda keep the
iterator contract:
+ # the user function still consumes and produces an iterator of
batches; the worker only
+ # feeds it the flattened elements and re-nests the results. See
ExtractPythonUDFFromLambda.
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_MAP_PANDAS_ITER_UDF,
PythonEvalType.SQL_MAP_ARROW_ITER_UDF,
PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF_WITH_STATE,
@@ -607,6 +612,7 @@ def read_single_udf(pickleSer, udf_info, eval_type,
runner_conf, udf_index):
if eval_type in (
PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_ARROW_BATCHED_UDF,
):
func = profiling_func
@@ -624,6 +630,10 @@ def read_single_udf(pickleSer, udf_info, eval_type,
runner_conf, udf_index):
if eval_type in (
PythonEvalType.SQL_ARROW_BATCHED_UDF,
PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF,
PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF,
PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF,
@@ -1819,10 +1829,100 @@ def read_udtf(pickleSer, udtf_info, eval_type,
runner_conf, eval_conf):
return mapper, None, ser, ser
+def _elementwise_renest(flat_values, shape_lengths, is_large):
+ """Re-nest a flat Array of per-element results into an ``array<R>`` column.
+
+ ``flat_values`` holds the results for every non-null element in order;
``shape_lengths`` is
+ the per-array element count of the iterated argument (``None`` for a null
array, which stays
+ null and consumes no elements). ``is_large`` preserves the input's list
width (``ListArray``
+ with int32 offsets vs. ``LargeListArray`` with int64).
+
+ Shared by the vectorized element-wise worker paths (scalar pandas / Arrow
and their iterator
+ variants) that back Python UDFs inside higher-order function lambdas. See
+ ``ExtractPythonUDFFromLambda``.
+ """
+ import pyarrow as pa
+
+ offsets = [0]
+ running = 0
+ mask = []
+ for n in shape_lengths:
+ mask.append(n is None)
+ if n is not None:
+ running += n
+ offsets.append(running)
+ list_cls = pa.LargeListArray if is_large else pa.ListArray
+ offsets_arr = pa.array(offsets, type=pa.int64() if is_large else
pa.int32())
+ null_mask = pa.array(mask, type=pa.bool_())
+ return list_cls.from_arrays(offsets_arr, flat_values, mask=null_mask)
+
+
+def _elementwise_flatten_column(flat, element_type, is_pandas, runner_conf):
+ """Adapt one already-flattened ``array<T>`` element column to the
vectorized fn's input.
+
+ ``flat`` is the flattened element ``pa.Array`` (the caller flattens once
per batch and shares it
+ across fused UDFs). Returns it unchanged for the Arrow flavor, or
converted to a pandas Series /
+ DataFrame with the element type ``T`` for the pandas flavor. Shared by the
vectorized
+ element-wise worker paths that back Python UDFs inside higher-order
function lambdas. See
+ ``ExtractPythonUDFFromLambda``.
+ """
+ if not is_pandas:
+ return flat
+ from pyspark.sql.conversion import ArrowArrayToPandasConversion
+
+ return ArrowArrayToPandasConversion.convert(
+ flat,
+ element_type,
+ timezone=runner_conf.timezone,
+ struct_in_pandas="dict",
+ ndarray_as_list=False,
+ prefer_int_ext_dtype=runner_conf.prefer_int_ext_dtype,
+ df_for_struct=True,
+ )
+
+
+def _elementwise_result_to_arrow(result, return_type, arrow_element_type,
is_pandas, runner_conf):
+ """Convert one vectorized UDF result over the flat elements to a single
flat Arrow Array.
+
+ ``result`` is a pandas Series / DataFrame (pandas flavor) or a
``pa.Array`` (Arrow flavor); the
+ returned array holds one element per input element. The Arrow flavor is
coerced to
+ ``arrow_element_type`` (UTC-typed); the pandas flavor is typed by
``PandasToArrowConversion``
+ using the session timezone, so its timestamp type may differ from
``arrow_element_type`` -
+ callers that concatenate results must take the type from the returned
array, not assume UTC.
+ Shared by the vectorized element-wise worker paths. See
``ExtractPythonUDFFromLambda``.
+ """
+ import pyarrow as pa
+
+ if is_pandas:
+ batch = PandasToArrowConversion.convert(
+ [result],
+ StructType([StructField("_0", return_type)]),
+ timezone=runner_conf.timezone,
+ safecheck=runner_conf.safecheck,
+ arrow_cast=True,
+ prefers_large_types=runner_conf.use_large_var_types,
+ assign_cols_by_name=runner_conf.assign_cols_by_name,
+
int_to_decimal_coercion_enabled=runner_conf.int_to_decimal_coercion_enabled,
+ )
+ else:
+ batch = ArrowBatchTransformer.enforce_schema(
+ pa.RecordBatch.from_arrays([result], ["_0"]),
+ pa.schema([pa.field("_0", arrow_element_type)]),
+ safecheck=runner_conf.safecheck,
+ )
+ # PandasToArrowConversion / enforce_schema both return a pa.RecordBatch,
so column(0) is a
+ # single pa.Array (never a ChunkedArray).
+ return batch.column(0)
+
+
def read_udfs(pickleSer, udf_info_list, eval_type, runner_conf, eval_conf):
if eval_type in (
PythonEvalType.SQL_ARROW_BATCHED_UDF,
PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_SCALAR_PANDAS_UDF,
PythonEvalType.SQL_SCALAR_ARROW_UDF,
PythonEvalType.SQL_COGROUPED_MAP_PANDAS_UDF,
@@ -3122,6 +3222,268 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
# profiling is not supported for UDF
return func, None, ser, ser
+ if eval_type in (
+ PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+ ):
+ from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+
+ require_minimum_pyarrow_version()
+
+ import pyarrow as pa
+ import pyarrow.compute as pc
+
+ # A scalar pandas or Arrow UDF lifted out of a higher-order function's
lambda by
+ # ExtractPythonUDFFromLambda. Each argument arrives as ``array<T>``
aligned with the
+ # iterated array. We flatten each list column to its element column,
run the *vectorized*
+ # function once over that flat column (so it still receives a pandas
Series / DataFrame or a
+ # pa.Array, its native contract), then re-nest the flat result to
``array<R>`` using the
+ # input's offsets - one row in, one row out, one Python round trip per
batch.
+ is_pandas = eval_type ==
PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF
+
+ udf_infos = []
+ for udf_func, udf_args_offsets, udf_kwargs_offsets, udf_return_type in
udfs:
+ wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
+ udf_func, udf_args_offsets, udf_kwargs_offsets
+ )
+ # The UDF returns one value per element, so its declared return
type is the element
+ # type of the ``array<R>`` this operator produces.
+ arrow_element_type = to_arrow_type(
+ udf_return_type, timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types
+ )
+ udf_infos.append(
+ (wrapped_func, args_kwargs_offsets, udf_return_type,
arrow_element_type)
+ )
+ col_names = [f"_{i}" for i in range(len(udfs))]
+
+ if is_pandas:
+ import pandas as pd
+
+ # Each argument is ``array<T>``; the vectorized function must see the
element type ``T``.
+ element_types = [f.dataType.elementType for f in eval_conf.input_type]
+
+ def func(split_index: int, data: Iterator[pa.RecordBatch]) ->
Iterator[pa.RecordBatch]:
+ for input_batch in data:
+ # Flatten each list column to its element array once per batch
and share it across
+ # fused UDFs (the 102 path does the same), rather than
re-flattening per UDF.
+ flat_columns = [
+ _elementwise_flatten_column(
+ col.flatten(), element_types[i], is_pandas, runner_conf
+ )
+ for i, col in enumerate(input_batch.itercolumns())
+ ]
+
+ output_arrays = []
+ for wrapped_func, offsets, return_type, arrow_element_type in
udf_infos:
+ # Re-nest by this UDF's first argument's shape. Different
UDFs in one operator
+ # may iterate differently shaped arrays, so each re-nests
by its own argument.
+ shape = input_batch.column(offsets[0])
+ shape_lengths = pc.list_value_length(shape).to_pylist()
+ total_elements = sum(n for n in shape_lengths if n is not
None)
+
+ result = wrapped_func(*[flat_columns[o] for o in offsets])
+ if is_pandas:
+ if not hasattr(result, "__len__"):
+ pd_type = (
+ "pandas.DataFrame"
+ if isinstance(return_type, StructType)
+ else "pandas.Series"
+ )
+ raise PySparkTypeError(
+ errorClass="UDF_RETURN_TYPE",
+ messageParameters={
+ "expected": pd_type,
+ "actual": type(result).__name__,
+ },
+ )
+ # struct return type must be a DataFrame (matches the
base pandas path).
+ if isinstance(return_type, StructType) and not
isinstance(
+ result, pd.DataFrame
+ ):
+ raise PySparkValueError(
+ "Invalid return type. Please make sure that
the UDF returns a "
+ "pandas.DataFrame when the specified return
type is StructType."
+ )
+ # Verify the flat length before re-nesting so a
wrong-length result raises
+ # the friendly RESULT_ROWS_MISMATCH rather than an
opaque pyarrow error.
+ verify_result_row_count(len(result), total_elements)
+ else:
+ # Arrow flavor: a non-array-like result (e.g. a bare
int) raises the
+ # friendly UDF_RETURN_TYPE rather than a bare
TypeError from len(), matching
+ # the base SQL_SCALAR_ARROW_UDF path, and also checks
the flat length.
+ verify_scalar_result(result, total_elements)
+
+ flat_arr = _elementwise_result_to_arrow(
+ result, return_type, arrow_element_type, is_pandas,
runner_conf
+ )
+ nested = _elementwise_renest(
+ flat_arr, shape_lengths,
pa.types.is_large_list(shape.type)
+ )
+ output_arrays.append(nested)
+
+ yield pa.RecordBatch.from_arrays(output_arrays, col_names)
+
+ # profiling is not supported for UDF
+ return func, None, ser, ser
+
+ if eval_type in (
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
+ ):
+ from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+
+ require_minimum_pyarrow_version()
+
+ import collections
+
+ import pyarrow as pa
+ import pyarrow.compute as pc
+
+ is_pandas = eval_type ==
PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF
+ if is_pandas:
+ import pandas as pd
+
+ assert num_udfs == 1, "One SCALAR_*_ITER_ELEMENTWISE UDF expected
here."
+ udf_func, args_offsets, kwargs_offsets, return_type = udfs[0]
+ assert not kwargs_offsets, "Iterator UDFs do not take keyword
arguments."
+
+ # A scalar iterator UDF (pandas or Arrow) lifted out of a higher-order
function's lambda.
+ # The user function keeps its iterator contract: it consumes an
iterator of batches and
+ # yields an iterator of batches, one output value per input value. We
preserve that by
+ # feeding it the *flattened* elements of each input batch and, since
the JVM joins UDF
+ # output to input positionally by row (one ``array<R>`` per input
``array<T>`` row, in
+ # order), buffering a FIFO of the per-row element counts to re-group
the streamed flat
+ # results back into arrays. Output batch boundaries need not match
input ones.
+ # Each argument is ``array<T>``; the pandas function must see each
argument's own element
+ # type ``T`` (arguments may differ, e.g. an outer column repeated into
an aligned array).
+ element_types = [f.dataType.elementType for f in eval_conf.input_type]
+ arrow_element_type = to_arrow_type(
+ return_type, timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types
+ )
+ is_large = None # set from the first input batch; the list width is
uniform per column.
+
+ def func(split_index: int, data: Iterator[pa.RecordBatch]) ->
Iterator[pa.RecordBatch]:
+ # FIFO of per-row element counts (None for a null array) awaiting
their flat results,
+ # and the flat elements produced so far but not yet enough to
complete the head shapes.
+ pending_shapes: "collections.deque" = collections.deque()
+ num_input_elements = 0
+
+ def extract_flat(batch: pa.RecordBatch):
+ nonlocal is_large, num_input_elements
+ shape = batch.column(args_offsets[0])
+ if is_large is None:
+ is_large = pa.types.is_large_list(shape.type)
+ pending_shapes.append(pc.list_value_length(shape).to_pylist())
+ # Flatten each argument to its element column; the user
function sees the flat
+ # elements as a pandas Series / DataFrame (pandas) or a
pa.Array (Arrow). Each
+ # argument is converted with its own element type.
+ flat_cols = [
+ _elementwise_flatten_column(
+ batch.column(o).flatten(), element_types[o],
is_pandas, runner_conf
+ )
+ for o in args_offsets
+ ]
+ num_input_elements += len(flat_cols[0])
+ return flat_cols[0] if len(flat_cols) == 1 else
tuple(flat_cols)
+
+ flat_args_iter = map(extract_flat, data)
+
+ if not is_pandas:
+ verified_iter = verify_return_type(
+ udf_func(flat_args_iter),
+ Iterator[pa.Array], # type: ignore[type-abstract]
+ )
+ else:
+ pandas_iter_type = (
+ Iterator[pd.DataFrame]
+ if isinstance(return_type, StructType)
+ else Iterator[pd.Series]
+ )
+ verified_iter = verify_return_type(udf_func(flat_args_iter),
pandas_iter_type)
+
+ # Buffer the streamed flat element results and emit an
``array<R>`` row as soon as the
+ # shape at the head of the FIFO is fully covered. A row whose
length is 0 (an empty
+ # array) or None (a null array) needs no elements, so it is
emitted immediately even
+ # before any chunk arrives - this matters when a whole partition
is empty/null arrays
+ # and the UDF yields nothing, otherwise those rows would be
dropped by the positional
+ # JVM join. Chunks are held in a list and concatenated only when a
shape spans more than
+ # one, so a UDF that yields once per input batch (the common case)
never re-copies the
+ # buffer. ``empty_type`` supplies the element type for a
zero-length emit; it tracks the
+ # most recent chunk's type (even a zero-length chunk carries the
flavor's type - the
+ # pandas flavor types timestamps with the session timezone),
falling back to the
+ # UTC-typed ``arrow_element_type`` only before any chunk arrives,
so all emitted batches
+ # share one schema.
+ pending_chunks: "list" = []
+ pending_len = 0
+ empty_type = arrow_element_type
+ num_output_elements = 0
+
+ def emit_ready():
+ nonlocal pending_chunks, pending_len
+ while pending_shapes:
+ lengths = pending_shapes[0]
+ needed = sum(n for n in lengths if n is not None)
+ if needed > pending_len:
+ break
+ pending_shapes.popleft()
+ if needed == 0:
+ flat = pa.nulls(0, type=empty_type)
+ else:
+ combined = (
+ pending_chunks[0]
+ if len(pending_chunks) == 1
+ else pa.concat_arrays(pending_chunks)
+ )
+ flat = combined.slice(0, needed)
+ remainder = combined.slice(needed)
+ pending_chunks = [remainder] if len(remainder) else []
+ pending_len -= needed
+ nested = _elementwise_renest(flat, lengths, bool(is_large))
+ yield pa.RecordBatch.from_arrays([nested], ["_0"])
+
+ def process_results():
+ nonlocal pending_chunks, pending_len, empty_type,
num_output_elements
+ for result in verified_iter:
+ if is_pandas:
+ verify_pandas_result(
+ result,
+ return_type,
+ assign_cols_by_name=True,
+ truncate_return_schema=True,
+ )
+ chunk = _elementwise_result_to_arrow(
+ result, return_type, arrow_element_type, is_pandas,
runner_conf
+ )
+ num_output_elements += len(chunk)
+ # Fail fast if the UDF over-produces, before the buffer
grows unbounded (the
+ # base iterator paths do the same via
verify_output_row_limit).
+ if num_output_elements > num_input_elements:
+ raise PySparkRuntimeError(
+ errorClass="OUTPUT_EXCEEDS_INPUT_ROWS",
messageParameters={}
+ )
+ # Even a zero-length chunk carries the flavor's element
type (the pandas flavor
+ # types timestamps with the session timezone), so always
take it: otherwise
+ # rows emitted for an all-empty batch before the first
non-empty chunk would use
+ # the UTC-typed default and disagree with later batches,
breaking the output
+ # stream's single-schema contract.
+ empty_type = chunk.type
+ if len(chunk):
+ pending_chunks.append(chunk)
+ pending_len += len(chunk)
+ yield from emit_ready()
+
+ # The iterator is exhausted: every input row's flat elements
must have arrived.
+ verify_result_row_count(num_output_elements,
num_input_elements)
+ # Flush any residual all-empty / all-null rows (they consume
no elements).
+ if pending_shapes:
+ yield from emit_ready()
+ verify_iterator_exhausted(flat_args_iter)
+
+ yield from process_results()
+
+ # profiling is not supported for UDF
+ return func, None, ser, ser
+
if eval_type == PythonEvalType.SQL_SCALAR_PANDAS_UDF:
import pandas as pd
import pyarrow as pa
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
index ec7e7832bb65..e421d946a669 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
@@ -38,8 +38,13 @@ object PythonUDF {
PythonEvalType.SQL_ARROW_BATCHED_UDF,
// Element-wise UDFs are row-shaped from the plan's point of view: one
array column in, one
// array column out per row. They are extracted by `ExtractPythonUDFs`
like any other scalar
- // UDF; only the Python worker treats them element-wise.
+ // UDF; only the Python worker treats them element-wise. One eval type per
lifted flavor keeps
+ // the worker's pandas- vs. Arrow-shaped batching and the iterator
contract distinct.
PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_SCALAR_PANDAS_UDF,
PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
PythonEvalType.SQL_SCALAR_ARROW_UDF,
@@ -54,15 +59,20 @@ object PythonUDF {
* Whether `e` is a Python UDF that can be lifted out of a higher-order
function's lambda by
* `ExtractPythonUDFFromLambda`, which applies it to the whole array outside
the lambda.
*
- * Only the row-at-a-time eval types qualify, since the rule lifts a UDF
that takes one value per
- * call. Otherwise-eligible shapes are excluded because the rewrite cannot
preserve them:
+ * Both the row-at-a-time eval types (plain and Arrow batched) and the
vectorized scalar eval
+ * types (scalar pandas / Arrow and their iterator variants) qualify: the
rule lifts the UDF
+ * structurally over `array<T>` arguments, and the Python worker flattens
each array, invokes the
+ * function on the flat element column with its own batching contract, and
re-nests. See
+ * [[liftedElementwiseEvalType]] for the mapping to the eval type the lifted
UDF runs under.
+ *
+ * Otherwise-eligible shapes are excluded because the rewrite cannot
preserve them:
* - a zero-argument call, `f()`: the lift turns each argument into an
aligned array, so with no
* argument there is no array to carry the iterated shape, and the
element-wise UDF would
* reach the worker with no input column and crash there instead of
failing analysis;
* - a call with named arguments: its `NamedArgumentExpression` children
would be buried inside
* the generated `ArrayTransform`, losing the kwargs mapping the runner
derives from the
* direct children;
- * - a UDF whose argument or return type involves a UDT: the lift forces
the Arrow element-wise
+ * - a UDF whose argument or return type involves a UDT: the lift forces
an Arrow element-wise
* eval type, which has no UDT fallback (unlike `correctEvalType`'s
Arrow -> pickle path), so
* it would fail at runtime instead of at analysis.
* All keep the previous behavior (an analysis error) rather than being
rewritten.
@@ -72,8 +82,7 @@ object PythonUDF {
*/
def isElementwiseRewritableUDF(e: Expression): Boolean = e match {
case udf: PythonUDF =>
- (udf.evalType == PythonEvalType.SQL_BATCHED_UDF ||
- udf.evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) &&
+ isElementwiseRewritableEvalType(udf.evalType) &&
udf.children.nonEmpty &&
!udf.children.exists(_.isInstanceOf[NamedArgumentExpression]) &&
!containsUDT(udf.dataType) &&
@@ -81,6 +90,38 @@ object PythonUDF {
case _ => false
}
+ private def isElementwiseRewritableEvalType(evalType: Int): Boolean =
evalType match {
+ case PythonEvalType.SQL_BATCHED_UDF |
+ PythonEvalType.SQL_ARROW_BATCHED_UDF |
+ PythonEvalType.SQL_SCALAR_PANDAS_UDF |
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF |
+ PythonEvalType.SQL_SCALAR_ARROW_UDF |
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF => true
+ case _ => false
+ }
+
+ /**
+ * The eval type a rewritable UDF runs under once lifted out of the lambda.
Each maps to the
+ * element-wise flavor that preserves its worker contract: the row-at-a-time
types share the one
+ * pickle-based element-wise path, while each vectorized scalar type keeps
its own pandas- vs.
+ * Arrow-shaped batching and iterator behavior. `evalType` must satisfy
+ * [[isElementwiseRewritableEvalType]].
+ */
+ def liftedElementwiseEvalType(evalType: Int): Int = evalType match {
+ case PythonEvalType.SQL_BATCHED_UDF | PythonEvalType.SQL_ARROW_BATCHED_UDF
=>
+ PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF
+ case PythonEvalType.SQL_SCALAR_PANDAS_UDF =>
+ PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF
+ case PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF =>
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF
+ case PythonEvalType.SQL_SCALAR_ARROW_UDF =>
+ PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF
+ case PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF =>
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF
+ case other =>
+ throw internalError(s"Not a rewritable elementwise UDF eval type:
$other")
+ }
+
/**
* Whether every Python UDF in `hof`'s lambdas can be lifted out by
`ExtractPythonUDFFromLambda`.
* Used by `CheckAnalysis` to decide whether to reject the plan. These
shapes cannot be rewritten:
@@ -88,7 +129,6 @@ object PythonUDF {
* `i` is not a real column. (A UDF in a nested *argument*,
`transform(arr, x ->
* transform(udf(x), y -> y))`, is fine - `udf(x)` lifts onto `arr`.)
* - a UDF in `aggregate` / `reduce`: the fold is sequential, so it sees
earlier steps' outputs;
- * - a vectorized (scalar pandas / arrow) UDF, which is not supported;
* - a *nondeterministic iterated argument*, `filter(shuffle(arr), x ->
f(x))`: the rewrite
* references that argument several times (the carrier's `c0`, each
lifted UDF's argument, the
* `map_keys`/`map_values` desugar, the pairwise `array_sort` path), and
nondeterministic
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
index 7c7ed9c50ab6..5206b15877c5 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
@@ -71,6 +71,12 @@ private[spark] class BatchIterator[T](iter: Iterator[T],
batchSize: Int)
* <li> SQL_SCALAR_ARROW_ITER_UDF for Scalar Iterator Arrow UDF
* <li> SQL_SCALAR_PANDAS_UDF for Scalar Pandas UDF
* <li> SQL_SCALAR_PANDAS_ITER_UDF for Scalar Iterator Pandas UDF
+ * <li> SQL_ARROW_ELEMENTWISE_UDF for a row-at-a-time UDF lifted out of a
higher-order function's
+ * lambda (see ExtractPythonUDFFromLambda)
+ * <li> SQL_SCALAR_PANDAS_ELEMENTWISE_UDF for a Scalar Pandas UDF lifted out
of such a lambda
+ * <li> SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF for a Scalar Iterator Pandas
UDF lifted out of one
+ * <li> SQL_SCALAR_ARROW_ELEMENTWISE_UDF for a Scalar Arrow UDF lifted out
of such a lambda
+ * <li> SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF for a Scalar Iterator Arrow
UDF lifted out of one
* </ul>
*
*/
@@ -163,6 +169,10 @@ case class ArrowEvalPythonExec(
Array(
PythonEvalType.SQL_ARROW_BATCHED_UDF,
PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_SCALAR_ARROW_UDF,
PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF,
PythonEvalType.SQL_SCALAR_PANDAS_UDF,
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
index 4c1c472cb10d..d0383c32a889 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
@@ -139,8 +139,10 @@ class ArrowPythonWithNamedArgumentRunner(
override protected def evalConf: Map[String, String] = {
// An element-wise UDF receives each argument as an `array<T>` column and
flattens it, so like
- // the Arrow batched UDF it needs the input schema to convert the incoming
Arrow types.
- if (evalType == PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF ||
+ // the Arrow batched UDF it needs the input schema to convert the incoming
Arrow types. This
+ // covers the row-at-a-time lift (SQL_ARROW_ELEMENTWISE_UDF) as well as
the vectorized lifts
+ // (scalar pandas / Arrow, and their iterator variants), which flatten the
same way.
+ if (PythonEvalType.isElementwiseUDF(evalType) ||
evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) {
super.evalConf ++ Map(
"input_type" -> schema.json
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambda.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambda.scala
index 637ec259c651..cab6c9895ca9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambda.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambda.scala
@@ -17,7 +17,6 @@
package org.apache.spark.sql.execution.python
-import org.apache.spark.api.python.PythonEvalType
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
import org.apache.spark.sql.catalyst.rules.Rule
@@ -40,10 +39,15 @@ import org.apache.spark.sql.types.{ArrayType, IntegerType,
MapType}
* transform(arrays_zip(values AS c0, plus_one_over_array(values) AS u0), s
-> s.u0)
* }}}
*
- * `plus_one_over_array` is the same function re-typed as `array<T> =>
array<R>` and run with
- * [[PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF]]. The array-at-a-time behaviour
lives in the Python
- * worker: it flattens each list column once, calls the function over all
elements of the batch, and
- * re-nests by the input's offsets - one row in, one row out, one Python round
trip per batch.
+ * `plus_one_over_array` is the same function re-typed as `array<T> =>
array<R>` and run with an
+ * element-wise eval type chosen from the UDF's own flavor (see
+ * [[PythonUDF.liftedElementwiseEvalType]]): the row-at-a-time UDFs share the
pickle-based
+ * [[org.apache.spark.api.python.PythonEvalType]]'s SQL_ARROW_ELEMENTWISE_UDF,
while a scalar pandas
+ * / Arrow UDF (and its iterator variant) lifts to its own element-wise type
so the worker keeps
+ * that flavor's batching contract.
+ * The array-at-a-time behaviour lives in the Python worker: it flattens each
list column once,
+ * calls the function over all elements of the batch, and re-nests by the
input's offsets - one row
+ * in, one row out, one Python round trip per batch.
*
* Every lifted argument is a single-level `array<T>` aligned with the
iterated array (an
* element-independent value is repeated into one with a native `transform`),
so the worker flattens
@@ -61,7 +65,6 @@ import org.apache.spark.sql.types.{ArrayType, IntegerType,
MapType}
* transform(udf(x), y -> y))`, is fine - `udf(x)` lifts onto `arr`.)
* - a UDF in `aggregate` / `reduce`: the fold is sequential, so the UDF sees
earlier steps'
* outputs, not array elements.
- * - a vectorized (scalar pandas / arrow) UDF, which is not supported.
*/
object ExtractPythonUDFFromLambda extends Rule[LogicalPlan] {
@@ -397,7 +400,10 @@ object ExtractPythonUDFFromLambda extends
Rule[LogicalPlan] {
// containsNull = true.
ArrayType(udf.dataType, containsNull = true),
arrayArgs,
- PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+ // Each rewritable flavor lifts to its own element-wise eval type so
the worker keeps that
+ // flavor's batching contract (pickle row-at-a-time, pandas Series,
Arrow Array, or an
+ // iterator of batches). See `PythonUDF.liftedElementwiseEvalType`.
+ PythonUDF.liftedElementwiseEvalType(udf.evalType),
udf.udfDeterministic)
val signature: Expression = if (udf.udfDeterministic)
lifted.canonicalized else lifted
val ordinal = ordinalBySignature.getOrElseUpdate(signature, {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
index 7a9ec702fea8..c290ec04deda 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
@@ -241,7 +241,12 @@ object ExtractPythonUDFs extends Rule[LogicalPlan] with
Logging {
def canChainWithParallelUDFs(evalType: Int): Boolean = {
if (evalType == PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF ||
- evalType == PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF) {
+ evalType == PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF ||
+ // The lifted iterator flavors carry the same one-UDF-per-operator
constraint as their base
+ // iterator types: the worker feeds a single iterator UDF, so they
must not be
+ // parallel-fused with siblings (e.g. `transform(arr, x -> f(x) +
g(x))` for iterator UDFs).
+ evalType == PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF ||
+ evalType == PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF) {
false
} else {
evalType == firstVisitedScalarUDFEvalType.get
@@ -350,6 +355,10 @@ object ExtractPythonUDFs extends Rule[LogicalPlan] with
Logging {
| PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF
| PythonEvalType.SQL_ARROW_BATCHED_UDF
| PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF
+ | PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF
+ | PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF
+ | PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF
+ | PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF
| PythonEvalType.SQL_SCALAR_ARROW_UDF
| PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF =>
ArrowEvalPython(validUdfs, resultAttrs, child, evalType)
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 d0228f9341c1..34d63df710cb 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
@@ -368,6 +368,27 @@ class MyDummyScalarPandasUDF extends
UserDefinedPythonFunction(
pythonEvalType = PythonEvalType.SQL_SCALAR_PANDAS_UDF,
udfDeterministic = true)
+class MyDummyScalarArrowUDF extends UserDefinedPythonFunction(
+ name = "dummyScalarArrowUDF",
+ func = new DummyUDF,
+ dataType = BooleanType,
+ pythonEvalType = PythonEvalType.SQL_SCALAR_ARROW_UDF,
+ udfDeterministic = true)
+
+class MyDummyScalarPandasIterUDF extends UserDefinedPythonFunction(
+ name = "dummyScalarPandasIterUDF",
+ func = new DummyUDF,
+ dataType = BooleanType,
+ pythonEvalType = PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
+ udfDeterministic = true)
+
+class MyDummyScalarArrowIterUDF extends UserDefinedPythonFunction(
+ name = "dummyScalarArrowIterUDF",
+ func = new DummyUDF,
+ dataType = BooleanType,
+ pythonEvalType = PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF,
+ udfDeterministic = true)
+
class MyDummyUDTPythonUDF extends UserDefinedPythonFunction(
name = "dummyUDTUDF",
func = new DummyUDF,
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambdaSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambdaSuite.scala
index b764b5c9cf9d..6ec31e67ca69 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambdaSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambdaSuite.scala
@@ -41,6 +41,9 @@ class ExtractPythonUDFFromLambdaSuite extends QueryTest with
SharedSparkSession
private val pythonUDF = new MyDummyPythonUDF
private val scalarPandasUDF = new MyDummyScalarPandasUDF
+ private val scalarArrowUDF = new MyDummyScalarArrowUDF
+ private val scalarPandasIterUDF = new MyDummyScalarPandasIterUDF
+ private val scalarArrowIterUDF = new MyDummyScalarArrowIterUDF
// Used where a UDF call must receive two arguments, e.g. a pairwise
comparator.
private val pythonUDF2 = new MyDummyPythonUDF
private val nondeterministicUDF = new MyDummyNondeterministicPythonUDF
@@ -262,11 +265,24 @@ class ExtractPythonUDFFromLambdaSuite extends QueryTest
with SharedSparkSession
assert(liftedUDFs(df).size == 1)
}
- test("a pandas UDF inside a lambda still fails analysis") {
- val e = intercept[AnalysisException] {
- arrayDF.select(transform(col("values"), x =>
scalarPandasUDF(x))).collect()
+ test("a vectorized scalar UDF inside a lambda is lifted to its element-wise
eval type") {
+ // Each vectorized scalar flavor lifts to its own element-wise eval type
so the worker keeps
+ // that flavor's batching contract (pandas Series, Arrow Array, or an
iterator of batches).
+ val cases = Seq(
+ scalarPandasUDF -> PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+ scalarArrowUDF -> PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+ scalarPandasIterUDF ->
PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ scalarArrowIterUDF ->
PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF)
+ cases.foreach { case (udf, expectedEvalType) =>
+ val df = arrayDF.select(transform(col("values"), x => udf(x)).as("r"))
+ val evalTypeName = PythonEvalType.toString(expectedEvalType)
+ assert(udfsInsideLambda(df).isEmpty, s"UDF still inside a lambda for
$evalTypeName")
+ val lifted = liftedUDFs(df)
+ assert(lifted.size == 1)
+ assert(lifted.head.evalType == expectedEvalType)
+ assert(lifted.head.dataType == ArrayType(udf.dataType, containsNull =
true))
+ assert(lifted.head.children.head.dataType.isInstanceOf[ArrayType])
}
- assert(e.getCondition ==
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF")
}
test("a UDF with a UDT type inside a lambda still fails analysis") {
@@ -288,6 +304,23 @@ class ExtractPythonUDFFromLambdaSuite extends QueryTest
with SharedSparkSession
PythonEvalType.SQL_BATCHED_UDF, udfDeterministic = true)
assert(PythonUDF.isElementwiseRewritableUDF(plain))
+ // Every row-at-a-time and vectorized scalar eval type is rewritable; each
maps to its own
+ // element-wise lifted eval type.
+ Seq(
+ PythonEvalType.SQL_ARROW_BATCHED_UDF ->
PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_UDF ->
PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF ->
+ PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_UDF ->
PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF ->
+ PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF).foreach {
+ case (base, lifted) =>
+ assert(PythonUDF.isElementwiseRewritableUDF(plain.copy(evalType =
base)))
+ assert(PythonUDF.liftedElementwiseEvalType(base) == lifted)
+ }
+ assert(PythonUDF.liftedElementwiseEvalType(PythonEvalType.SQL_BATCHED_UDF)
==
+ PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF)
+
val zeroArg = plain.copy(children = Seq.empty)
assert(!PythonUDF.isElementwiseRewritableUDF(zeroArg))
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]