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 e068d117232c [SPARK-58890][SQL][PYTHON][CONNECT] Support incremental
Python aggregators over window functions
e068d117232c is described below
commit e068d117232c267365437f1b5fb33b1459355447
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Thu Aug 20 12:58:35 2026 +0900
[SPARK-58890][SQL][PYTHON][CONNECT] Support incremental Python aggregators
over window functions
### What changes were proposed in this pull request?
A follow-up to
[SPARK-58736](https://issues.apache.org/jira/browse/SPARK-58736) (incremental
Python
`Aggregator` / `udaf`). This lets an incremental aggregator be used as a
**window function** --
`df.withColumn("m", udaf(agg)(col).over(window))` -- which previously
failed analysis with
`UNSUPPORTED_EXPR_FOR_WINDOW`.
A window has no shuffle, so it reuses neither the map-side PARTIAL nor the
post-shuffle FINAL
two-stage eval type. A new eval type `SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF`
(257) routes the
aggregator through the **existing Arrow window operator**
(`ArrowWindowPythonExec`), which sends
each frame to the Python worker -- the whole partition for an unbounded
frame, or per-row
`[begin, end)` slices for a bounded one. The worker folds the frame's rows
with `reduce` from a
fresh `zero` and produces the value with `finish` (one output value per
input row); `merge` is not
used on the window path.
Concretely:
- Route `PythonAggregate` to the Python window path:
`PythonUDF.isWindowPandasUDF` and
`WindowFunctionType.pythonEvalType` now recognize it, and the
`WindowResolution` reject guard is
removed.
- Generalize `ArrowWindowPythonEvaluatorFactory` from
`asInstanceOf[PythonUDAF]` to
`PythonFuncExpression` (both `PythonUDAF` and `PythonAggregate` extend
it) and add the new eval
type to `ArrowWindowPythonExec`'s supported set. All the frame-boundary
machinery is reused.
- Add the worker handler for the new eval type in `worker.py`.
No Spark Connect-specific change is needed: a window-over-aggregator
resolves through the same
server-side Catalyst rules.
This PR also **relocates the `udaf` factory to `pyspark.sql.functions`**
(next to `udf` /
`pandas_udf`), matching Scala's `functions.udaf`, and stops exposing it from
`pyspark.sql.aggregator`. `Aggregator` (the base class users subclass)
stays in
`pyspark.sql.aggregator`. `udaf` is unreleased (added on `master` /
`branch-4.x` for SPARK-58736),
so this only changes the import path within unreleased branches:
```python
from pyspark.sql.aggregator import Aggregator # base class to subclass
from pyspark.sql.functions import udaf # was: from
pyspark.sql.aggregator import udaf
```
### Why are the changes needed?
Parity. Grouped-agg pandas/arrow UDFs already work as window functions, and
so does the Scala
`Aggregator` (via the JVM SQL window path). The incremental Python
aggregator is presented as the
analog of the Scala `Aggregator`, so rejecting it over a window was an
inconsistent limitation.
### Does this PR introduce _any_ user-facing change?
Yes. `udaf(...).over(window)` now works; previously it raised
`UNSUPPORTED_EXPR_FOR_WINDOW` at
analysis. Additionally, `udaf` is now imported from `pyspark.sql.functions`
rather than
`pyspark.sql.aggregator`. Both are relative to the unreleased
incremental-aggregator feature
(SPARK-58736); no released behavior changes.
### How was this patch tested?
New tests in
`python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py`, run for both
classic
and Spark Connect (via the parity suite):
- `test_window_unbounded` -- unbounded partition frame.
- `test_window_running_frame` -- ordered growing frame (unbounded preceding
.. current row).
- `test_window_sliding_frame` -- sliding frame (1 preceding .. 1 following)
with a custom
single-field buffer aggregator.
Each cross-checks the aggregator's result against the equivalent SQL window
aggregate. Ran locally:
classic 26/26 and Spark Connect parity 24/24 pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes #58142 from HyukjinKwon/python-aggregator-window.
Authored-by: Hyukjin Kwon <[email protected]>
Signed-off-by: Hyukjin Kwon <[email protected]>
---
.../src/main/resources/error/error-conditions.json | 5 +
.../org/apache/spark/api/python/PythonRunner.scala | 6 +
.../source/reference/pyspark.sql/functions.rst | 1 +
python/pyspark/sql/aggregator.py | 92 +--------------
python/pyspark/sql/connect/functions/builtin.py | 6 +-
python/pyspark/sql/functions/__init__.py | 1 +
python/pyspark/sql/functions/builtin.py | 126 +++++++++++++++++++++
python/pyspark/sql/pandas/_typing/__init__.pyi | 1 +
.../tests/arrow/test_arrow_python_aggregator.py | 93 ++++++++++++++-
python/pyspark/sql/tests/test_functions.py | 1 -
python/pyspark/util.py | 5 +
python/pyspark/worker.py | 98 ++++++++++++++++
.../sql/catalyst/analysis/WindowResolution.scala | 10 --
.../spark/sql/catalyst/expressions/PythonUDF.scala | 9 +-
.../catalyst/expressions/windowExpressions.scala | 3 +
.../spark/sql/errors/QueryCompilationErrors.scala | 6 +
.../python/ArrowWindowPythonEvaluatorFactory.scala | 8 +-
.../execution/python/ArrowWindowPythonExec.scala | 17 ++-
18 files changed, 371 insertions(+), 117 deletions(-)
diff --git a/common/utils/src/main/resources/error/error-conditions.json
b/common/utils/src/main/resources/error/error-conditions.json
index 2b7c146fcf99..c8bdaa4a48f7 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -8886,6 +8886,11 @@
"Multiple bucket TRANSFORMs."
]
},
+ "MULTIPLE_PYTHON_UDF_TYPES_IN_WINDOW" : {
+ "message" : [
+ "Cannot use Python user-defined functions of different types
together over a single window: <functionList>. Use a separate window
specification for each."
+ ]
+ },
"MULTI_ACTION_ALTER" : {
"message" : [
"The target JDBC server hosting table <tableName> does not support
ALTER TABLE with multiple actions. Split the ALTER TABLE up into individual
actions to avoid this error."
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 015067766c9c..e643045134f4 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
@@ -95,6 +95,11 @@ private[spark] object PythonEvalType {
val SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF = 255
val SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF = 256
+ // Window aggregation with an incremental Arrow aggregator. A window has no
shuffle, so it needs
+ // neither the PARTIAL nor the FINAL eval type above: the operator sends
each frame's rows to the
+ // worker, which folds them with `reduce` (from `zero`) and produces the
value with `finish`.
+ val SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF = 257
+
val SQL_TABLE_UDF = 300
val SQL_ARROW_TABLE_UDF = 301
val SQL_ARROW_UDTF = 302
@@ -142,6 +147,7 @@ private[spark] object PythonEvalType {
"SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF"
case SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF =>
"SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF"
+ case SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF =>
"SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF"
}
// The eval types produced by ExtractPythonUDFFromLambda: a scalar UDF
lifted out of a
diff --git a/python/docs/source/reference/pyspark.sql/functions.rst
b/python/docs/source/reference/pyspark.sql/functions.rst
index a79922c8051d..229fcaf6c381 100644
--- a/python/docs/source/reference/pyspark.sql/functions.rst
+++ b/python/docs/source/reference/pyspark.sql/functions.rst
@@ -769,6 +769,7 @@ UDF, UDTF and UDT
arrow_udtf
call_udf
pandas_udf
+ udaf
udf
udtf
unwrap_udt
diff --git a/python/pyspark/sql/aggregator.py b/python/pyspark/sql/aggregator.py
index 77110b8f88a3..8ce383b9b4f2 100644
--- a/python/pyspark/sql/aggregator.py
+++ b/python/pyspark/sql/aggregator.py
@@ -22,11 +22,10 @@ Incremental user-defined aggregators for PySpark, the
Python analog of Scala's
from abc import ABC, abstractmethod
from typing import Any, Tuple
-from pyspark.errors import PySparkNotImplementedError, PySparkTypeError,
PySparkValueError
+from pyspark.errors import PySparkNotImplementedError
from pyspark.sql.types import DataType, StructType
-from pyspark.util import PythonEvalType
-__all__ = ["Aggregator", "udaf"]
+__all__ = ["Aggregator"]
class Aggregator(ABC):
@@ -52,7 +51,8 @@ class Aggregator(ABC):
--------
A mean aggregator::
- from pyspark.sql.aggregator import Aggregator, udaf
+ from pyspark.sql.aggregator import Aggregator
+ from pyspark.sql.functions import udaf
from pyspark.sql.types import StructType, StructField, DoubleType,
LongType
class Mean(Aggregator):
@@ -137,87 +137,3 @@ class Aggregator(ABC):
errorClass="NOT_IMPLEMENTED",
messageParameters={"feature": "calling an Aggregator directly;
wrap it with udaf(...)"},
)
-
-
-def udaf(agg: "Aggregator") -> Any:
- """
- Turn an :class:`Aggregator` instance into a callable usable in
``groupBy().agg(...)``, the
- Python counterpart of Scala's ``functions.udaf``.
-
- The aggregator is executed with true incremental (partial) aggregation and
transfers its
- intermediate buffer as Arrow; PyArrow is therefore required.
-
- .. versionadded:: 4.4.0
-
- Parameters
- ----------
- agg : :class:`Aggregator`
- The aggregator instance.
-
- Returns
- -------
- function
- A callable that, applied to input columns, produces an aggregate
:class:`Column`.
-
- Raises
- ------
- :class:`PySparkImportError`
- If a supported version of PyArrow is not installed.
- :class:`PySparkTypeError`
- If ``agg`` is not an :class:`Aggregator`, or its ``bufferSchema`` is
not a
- :class:`StructType`.
- """
- from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
- from pyspark.sql.utils import is_remote
-
- require_minimum_pyarrow_version()
-
- if is_remote():
- from pyspark.sql.connect.udf import UserDefinedFunction
- else:
- # The classic UserDefinedFunction is a distinct class from the Connect
one above;
- # both provide the same interface used below, so silence mypy's
reassignment check.
- from pyspark.sql.udf import UserDefinedFunction # type:
ignore[assignment]
-
- if not isinstance(agg, Aggregator):
- raise PySparkTypeError(
- errorClass="NOT_EXPECTED_TYPE",
- messageParameters={
- "arg_name": "agg",
- "expected_type": "Aggregator",
- "arg_type": type(agg).__name__,
- },
- )
- if not isinstance(agg.bufferSchema, StructType):
- raise PySparkTypeError(
- errorClass="NOT_EXPECTED_TYPE",
- messageParameters={
- "arg_name": "bufferSchema",
- "expected_type": "StructType",
- "arg_type": type(agg.bufferSchema).__name__,
- },
- )
- # The buffer crosses the shuffle as an Arrow struct whose children are
matched by name, and the
- # worker keys the buffer tuple back by field name. Duplicate names would
silently collapse
- # fields on the map side and then fail with an opaque Arrow error
post-shuffle, so reject them
- # up front where the aggregator is created.
- field_names = [field.name for field in agg.bufferSchema.fields]
- if len(field_names) != len(set(field_names)):
- duplicates = sorted({name for name in field_names if
field_names.count(name) > 1})
- raise PySparkValueError(
- errorClass="DUPLICATED_FIELD_NAME_IN_ARROW_STRUCT",
- messageParameters={"field_names": ", ".join(duplicates)},
- )
-
- # ``bufferSchema`` is a first-class ``UserDefinedFunction`` field
(threaded to the JVM in
- # ``_create_judf`` so ``PythonAggregate`` can plan the two-stage
aggregation), so it survives
- # ``_wrapped()`` and ``spark.udf.register`` without being re-attached.
- udf_obj = UserDefinedFunction(
- agg,
- returnType=agg.outputType,
- name=agg.__class__.__name__,
- evalType=PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
- deterministic=True,
- bufferSchema=agg.bufferSchema,
- )
- return udf_obj._wrapped()
diff --git a/python/pyspark/sql/connect/functions/builtin.py
b/python/pyspark/sql/connect/functions/builtin.py
index 8451ae386770..9ad1bf43cb63 100644
--- a/python/pyspark/sql/connect/functions/builtin.py
+++ b/python/pyspark/sql/connect/functions/builtin.py
@@ -68,9 +68,9 @@ from pyspark.sql.types import (
)
from pyspark.sql.utils import enum_to_value as _enum_to_value
-# The implementation of pandas_udf is embedded in
pyspark.sql.function.pandas_udf
-# for code reuse.
-from pyspark.sql.functions import arrow_udf, pandas_udf # noqa: F401
+# The implementations of pandas_udf, arrow_udf and udaf are embedded in
pyspark.sql.functions
+# (they select the classic vs Connect UserDefinedFunction internally), so
reuse them here.
+from pyspark.sql.functions import arrow_udf, pandas_udf, udaf # noqa: F401
if TYPE_CHECKING:
from pyspark.sql.connect._typing import (
diff --git a/python/pyspark/sql/functions/__init__.py
b/python/pyspark/sql/functions/__init__.py
index d20790f20bcb..001a7d4784ca 100644
--- a/python/pyspark/sql/functions/__init__.py
+++ b/python/pyspark/sql/functions/__init__.py
@@ -617,6 +617,7 @@ __all__ = [ # noqa: F405
# Call Functions
"call_udf",
"pandas_udf",
+ "udaf",
"udf",
"udtf",
"arrow_udtf",
diff --git a/python/pyspark/sql/functions/builtin.py
b/python/pyspark/sql/functions/builtin.py
index d5ec94fa6d62..a906b884872b 100644
--- a/python/pyspark/sql/functions/builtin.py
+++ b/python/pyspark/sql/functions/builtin.py
@@ -78,6 +78,7 @@ from pyspark.sql.utils import (
if TYPE_CHECKING:
from pyspark import SparkContext
+ from pyspark.sql.aggregator import Aggregator
from pyspark.sql.dataframe import DataFrame
from pyspark.sql._typing import (
ColumnOrName,
@@ -33059,6 +33060,130 @@ def bitmap_and_agg(col: "ColumnOrName") -> Column:
# ---------------------------- User Defined Function
----------------------------------
+def udaf(agg: "Aggregator") -> "UserDefinedFunctionLike":
+ """Turn an :class:`~pyspark.sql.aggregator.Aggregator` instance into a
callable usable in
+ ``groupBy().agg(...)`` (and as a window function), the Python counterpart
of Scala's
+ ``functions.udaf``.
+
+ The aggregator is executed with true incremental (partial) aggregation and
transfers its
+ intermediate buffer as Arrow; PyArrow is therefore required.
+
+ .. versionadded:: 4.4.0
+
+ Parameters
+ ----------
+ agg : :class:`~pyspark.sql.aggregator.Aggregator`
+ The aggregator instance.
+
+ Returns
+ -------
+ function
+ A callable that, applied to input columns, produces an aggregate
+ :class:`~pyspark.sql.Column`.
+
+ Raises
+ ------
+ :class:`PySparkImportError`
+ If a supported version of PyArrow is not installed.
+ :class:`PySparkTypeError`
+ If ``agg`` is not an :class:`~pyspark.sql.aggregator.Aggregator`, or
its ``bufferSchema`` is
+ not a :class:`StructType`.
+
+ Examples
+ --------
+ >>> from pyspark.sql.aggregator import Aggregator
+ >>> from pyspark.sql.functions import udaf
+ >>> from pyspark.sql.types import StructType, StructField, DoubleType,
LongType
+ >>> class Mean(Aggregator):
+ ... @property
+ ... def bufferSchema(self):
+ ... return StructType(
+ ... [StructField("sum", DoubleType()), StructField("count",
LongType())]
+ ... )
+ ...
+ ... @property
+ ... def outputType(self):
+ ... return DoubleType()
+ ...
+ ... def zero(self):
+ ... return (0.0, 0)
+ ...
+ ... def reduce(self, buffer, value):
+ ... (v,) = value
+ ... return buffer if v is None else (buffer[0] + v, buffer[1] + 1)
+ ...
+ ... def merge(self, b1, b2):
+ ... return (b1[0] + b2[0], b1[1] + b2[1])
+ ...
+ ... def finish(self, buffer):
+ ... return buffer[0] / buffer[1] if buffer[1] else None
+ >>> df = spark.createDataFrame([(1, 1.0), (1, 2.0), (2, 3.0)], ("k", "v"))
+ >>> df.groupBy("k").agg(udaf(Mean())(df.v).alias("m")).orderBy("k").show()
+ +---+---+
+ | k| m|
+ +---+---+
+ | 1|1.5|
+ | 2|3.0|
+ +---+---+
+ """
+ from pyspark.sql.aggregator import Aggregator
+ from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+ from pyspark.sql.utils import is_remote
+ from pyspark.util import PythonEvalType
+
+ require_minimum_pyarrow_version()
+
+ if is_remote():
+ from pyspark.sql.connect.udf import UserDefinedFunction
+ else:
+ # The classic UserDefinedFunction is a distinct class from the Connect
one above;
+ # both provide the same interface used below, so silence mypy's
reassignment check.
+ from pyspark.sql.udf import UserDefinedFunction # type:
ignore[assignment]
+
+ if not isinstance(agg, Aggregator):
+ raise PySparkTypeError(
+ errorClass="NOT_EXPECTED_TYPE",
+ messageParameters={
+ "arg_name": "agg",
+ "expected_type": "Aggregator",
+ "arg_type": type(agg).__name__,
+ },
+ )
+ if not isinstance(agg.bufferSchema, StructType):
+ raise PySparkTypeError(
+ errorClass="NOT_EXPECTED_TYPE",
+ messageParameters={
+ "arg_name": "bufferSchema",
+ "expected_type": "StructType",
+ "arg_type": type(agg.bufferSchema).__name__,
+ },
+ )
+ # The buffer crosses the shuffle as an Arrow struct whose children are
matched by name, and the
+ # worker keys the buffer tuple back by field name. Duplicate names would
silently collapse
+ # fields on the map side and then fail with an opaque Arrow error
post-shuffle, so reject them
+ # up front where the aggregator is created.
+ field_names = [field.name for field in agg.bufferSchema.fields]
+ if len(field_names) != len(set(field_names)):
+ duplicates = sorted({name for name in field_names if
field_names.count(name) > 1})
+ raise PySparkValueError(
+ errorClass="DUPLICATED_FIELD_NAME_IN_ARROW_STRUCT",
+ messageParameters={"field_names": ", ".join(duplicates)},
+ )
+
+ # ``bufferSchema`` is a first-class ``UserDefinedFunction`` field
(threaded to the JVM in
+ # ``_create_judf`` so ``PythonAggregate`` can plan the two-stage
aggregation), so it survives
+ # ``_wrapped()`` and ``spark.udf.register`` without being re-attached.
+ udf_obj = UserDefinedFunction(
+ agg,
+ returnType=agg.outputType,
+ name=agg.__class__.__name__,
+ evalType=PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+ deterministic=True,
+ bufferSchema=agg.bufferSchema,
+ )
+ return udf_obj._wrapped()
+
+
@overload
def udf(
f: Callable[..., Any],
@@ -33743,6 +33868,7 @@ def _test() -> None:
if not have_pandas or not have_pyarrow:
del pyspark.sql.functions.builtin.udf.__doc__
del pyspark.sql.functions.builtin.arrow_udtf.__doc__
+ del pyspark.sql.functions.builtin.udaf.__doc__
spark = (
SparkSession.builder.master("local[4]").appName("sql.functions.builtin
tests").getOrCreate()
diff --git a/python/pyspark/sql/pandas/_typing/__init__.pyi
b/python/pyspark/sql/pandas/_typing/__init__.pyi
index 442849bcbf0a..8e8d4f34783c 100644
--- a/python/pyspark/sql/pandas/_typing/__init__.pyi
+++ b/python/pyspark/sql/pandas/_typing/__init__.pyi
@@ -70,6 +70,7 @@ ArrowWindowAggUDFType = Literal[253]
ArrowGroupedAggIterUDFType = Literal[254]
ArrowGroupedAggIncrementalPartialUDFType = Literal[255]
ArrowGroupedAggIncrementalFinalUDFType = Literal[256]
+ArrowWindowAggIncrementalUDFType = Literal[257]
# Arrow stream types
# A single group of Arrow batches (e.g., one key group in groupBy).
diff --git a/python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py
b/python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py
index 3965d804ae84..692c2256edcc 100644
--- a/python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py
+++ b/python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py
@@ -36,7 +36,8 @@ from pyspark.testing.utils import (
if have_pyarrow:
- from pyspark.sql.aggregator import Aggregator, udaf
+ from pyspark.sql.aggregator import Aggregator
+ from pyspark.sql.functions import udaf
class Mean(Aggregator):
@property
@@ -280,12 +281,92 @@ class ArrowPythonAggregatorTestsMixin:
with self.assertRaises(AnalysisException):
df.groupBy("k").pivot("p").agg(udaf(Mean())(sf.col("v"))).collect()
- def test_window_rejected(self):
- # The incremental aggregator has no window operator; using it over a
window must fail
- # analysis rather than crash with an internal error at execution.
+ def test_window_unbounded(self):
+ # Unbounded partition frame: every row gets its whole group's
aggregate. Cross-checked
+ # against the equivalent SQL window aggregate.
df = self._data()
- with self.assertRaises(AnalysisException):
-
df.select(udaf(Mean())(sf.col("v")).over(Window.partitionBy("k"))).collect()
+ w = Window.partitionBy("k")
+ result = df.withColumn("m",
udaf(Mean())(sf.col("v")).over(w)).orderBy("k", "v").collect()
+ expected = df.withColumn("m", sf.avg("v").over(w)).orderBy("k",
"v").collect()
+ self.assertEqual(len(result), len(expected))
+ for r, e in zip(result, expected):
+ self.assertAlmostEqual(r["m"], e["m"], places=6)
+
+ def test_window_running_frame(self):
+ # Ordered, growing frame (unbounded preceding .. current row): a
running aggregate that
+ # exercises the per-row bounded-frame path in the worker.
+ df = self._data()
+ w = (
+ Window.partitionBy("k")
+ .orderBy("v")
+ .rowsBetween(Window.unboundedPreceding, Window.currentRow)
+ )
+ result = df.withColumn("m",
udaf(Mean())(sf.col("v")).over(w)).orderBy("k", "v").collect()
+ expected = df.withColumn("m", sf.avg("v").over(w)).orderBy("k",
"v").collect()
+ self.assertEqual(len(result), len(expected))
+ for r, e in zip(result, expected):
+ self.assertAlmostEqual(r["m"], e["m"], places=6)
+
+ def test_window_sliding_frame(self):
+ # Sliding frame (1 preceding .. 1 following) with a custom
single-field buffer aggregator.
+ df = self._data()
+ w = Window.partitionBy("k").orderBy("v").rowsBetween(-1, 1)
+ result = (
+ df.withColumn("s",
udaf(SumSquares())(sf.col("v")).over(w)).orderBy("k", "v").collect()
+ )
+ expected = (
+ df.withColumn("s", sf.sum(sf.col("v") * sf.col("v")).over(w))
+ .orderBy("k", "v")
+ .collect()
+ )
+ self.assertEqual(len(result), len(expected))
+ for r, e in zip(result, expected):
+ self.assertAlmostEqual(r["s"], e["s"], places=4)
+
+ def test_window_bounded_preceding_frame(self):
+ # A fixed number of preceding rows exercises both branches of the
running-buffer
+ # optimization: the lower bound is clamped to 0 for the first rows
(running buffer is
+ # extended in place) and then advances (each frame is refolded from
zero).
+ df = self._data()
+ w = Window.partitionBy("k").orderBy("v").rowsBetween(-3,
Window.currentRow)
+ result = df.withColumn("m",
udaf(Mean())(sf.col("v")).over(w)).orderBy("k", "v").collect()
+ expected = df.withColumn("m", sf.avg("v").over(w)).orderBy("k",
"v").collect()
+ self.assertEqual(len(result), len(expected))
+ for r, e in zip(result, expected):
+ self.assertAlmostEqual(r["m"], e["m"], places=6)
+
+ def test_window_decimal_output(self):
+ # A non-trivial (Decimal) output type over a window, exercising the
explicit
+ # ``pa.array(..., type=result_type)`` typing on the window path.
+ df = self._data()
+ w = Window.partitionBy("k")
+ result = (
+ df.withColumn("s",
udaf(DecimalSum())(sf.col("v")).over(w)).orderBy("k", "v").collect()
+ )
+ expected = df.withColumn("s", sf.sum("v").over(w)).orderBy("k",
"v").collect()
+ self.assertEqual(len(result), len(expected))
+ for r, e in zip(result, expected):
+ self.assertIsInstance(r["s"], Decimal)
+ self.assertAlmostEqual(float(r["s"]), e["s"], places=4)
+
+ def test_window_mixed_python_udf_rejected(self):
+ # An incremental aggregator and a grouped-agg pandas UDF over the same
window are both
+ # Python window functions but use different eval types, so they cannot
share one operator.
+ # This must raise a clear analysis error rather than an internal
assertion.
+ from pyspark.sql.functions import pandas_udf, PandasUDFType
+
+ @pandas_udf("double", PandasUDFType.GROUPED_AGG)
+ def pandas_mean(v):
+ return v.mean()
+
+ df = self._data()
+ w = Window.partitionBy("k")
+ with self.assertRaises(AnalysisException) as ctx:
+ df.select(udaf(Mean())(sf.col("v")).over(w),
pandas_mean(sf.col("v")).over(w)).collect()
+ self.assertEqual(
+ ctx.exception.getCondition(),
+ "UNSUPPORTED_FEATURE.MULTIPLE_PYTHON_UDF_TYPES_IN_WINDOW",
+ )
def test_mixed_with_other_aggregate_rejected(self):
# An incremental aggregator mixed with another aggregate in one
Aggregate is unsupported;
diff --git a/python/pyspark/sql/tests/test_functions.py
b/python/pyspark/sql/tests/test_functions.py
index 44c1047cbe3c..da8c13537ab8 100644
--- a/python/pyspark/sql/tests/test_functions.py
+++ b/python/pyspark/sql/tests/test_functions.py
@@ -65,7 +65,6 @@ class FunctionsTestsMixin:
"not", # equivalent to python ~expression
"any", # equivalent to python ~some
"len", # equivalent to python ~length
- "udaf", # used for creating UDAF's which are not supported in
PySpark
"partitioning$", # partitioning expressions for DSv2
]
diff --git a/python/pyspark/util.py b/python/pyspark/util.py
index 81bea6152f98..8b37a2577f93 100644
--- a/python/pyspark/util.py
+++ b/python/pyspark/util.py
@@ -88,6 +88,7 @@ if typing.TYPE_CHECKING:
ArrowGroupedAggIterUDFType,
ArrowGroupedAggIncrementalPartialUDFType,
ArrowGroupedAggIncrementalFinalUDFType,
+ ArrowWindowAggIncrementalUDFType,
ArrowWindowAggUDFType,
)
from pyspark.sql._typing import (
@@ -708,6 +709,10 @@ class PythonEvalType:
SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF:
"ArrowGroupedAggIncrementalPartialUDFType" = 255
SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF:
"ArrowGroupedAggIncrementalFinalUDFType" = 256
+ # Window aggregation with an incremental ``Aggregator``. A window has no
shuffle, so each
+ # frame's rows are folded with ``reduce`` (from ``zero``) and finished
with ``finish``.
+ SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF: "ArrowWindowAggIncrementalUDFType" =
257
+
SQL_TABLE_UDF: "SQLTableUDFType" = 300
SQL_ARROW_TABLE_UDF: "SQLArrowTableUDFType" = 301
SQL_ARROW_UDTF: "SQLArrowUDTFType" = 302
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 565323365c33..d92824f5a053 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -679,6 +679,7 @@ def read_single_udf(pickleSer, udf_info, eval_type,
runner_conf, udf_index):
if eval_type in (
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+ PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF,
):
return chained_func, args_offsets, kwargs_offsets, return_type
@@ -2066,6 +2067,7 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
PythonEvalType.SQL_TRANSFORM_WITH_STATE_PYTHON_ROW_INIT_STATE_UDF,
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+ PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF,
):
# NOTE: if timezone is set here, that implies respectSessionTimeZone
is True
if eval_type in (
@@ -2083,6 +2085,7 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF,
PythonEvalType.SQL_WINDOW_AGG_ARROW_UDF,
PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF,
+ PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF,
):
ser = ArrowStreamGroupSerializer(write_start_stream=True)
elif eval_type in (
@@ -2647,6 +2650,101 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
# profiling is not supported for UDF
return grouped_func, None, ser, ser
+ if eval_type == PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF:
+ import pyarrow as pa
+
+ # Window aggregation with an incremental ``Aggregator``. The operator
sends each frame --
+ # the whole partition for an unbounded frame, or per-row ``[begin,
end)`` slices for a
+ # bounded one -- and the worker folds the frame's rows with ``reduce``
(from a fresh
+ # ``zero``) and produces the value with ``finish``, one output value
per input row. A window
+ # has no shuffle, so the intermediate buffer never leaves the worker
(unlike the two-stage
+ # groupBy path); ``merge`` is not used here.
+ window_bound_types_str = runner_conf.get("window_bound_types")
+ window_bound_types = [t.strip().lower() for t in
window_bound_types_str.split(",")]
+
+ col_names = ["_%d" % i for i in range(len(udfs))]
+ return_schema = to_arrow_schema(
+ StructType([StructField(name, rt) for name, (_, _, _, rt) in
zip(col_names, udfs)]),
+ timezone="UTC",
+ prefers_large_types=runner_conf.use_large_var_types,
+ )
+
+ def fold(agg: Any, buffer: Any, value_cols: list, start: int, end:
int) -> Any:
+ # Fold rows ``[start, end)`` (each a tuple across ``value_cols``,
matching the call-site
+ # argument order) into ``buffer`` via the aggregator's ``reduce``.
+ for r in range(start, end):
+ buffer = agg.reduce(buffer, tuple(c[r] for c in value_cols))
+ return buffer
+
+ def grouped_func(
+ split_index: int, data: Iterator["GroupedBatch"]
+ ) -> Iterator[pa.RecordBatch]:
+ for group in data:
+ batch_list = list(group)
+ if not batch_list:
+ continue
+ if hasattr(pa, "concat_batches"):
+ concatenated = pa.concat_batches(batch_list)
+ else:
+ # pyarrow.concat_batches not supported before 19.0.0
+ # remove this once we drop support for old versions
+ concatenated = pa.RecordBatch.from_struct_array(
+ pa.concat_arrays([b.to_struct_array() for b in
batch_list])
+ )
+ num_rows = concatenated.num_rows
+
+ result_arrays = []
+ for udf_index, (agg, args_offsets, kwargs_offsets, _) in
enumerate(udfs):
+ bound_type = window_bound_types[udf_index]
+ result_type = return_schema.field(udf_index).type
+ if bound_type == "unbounded":
+ # One frame spanning the whole partition: compute
once, repeat per row.
+ value_cols = [concatenated.column(o).to_pylist() for o
in args_offsets] + [
+ concatenated.column(v).to_pylist() for v in
kwargs_offsets.values()
+ ]
+ result = agg.finish(fold(agg, agg.zero(), value_cols,
0, num_rows))
+ result_arrays.append(pa.array([result] * num_rows,
type=result_type))
+ elif bound_type == "bounded":
+ # Per-row frame ``[begin, end)``. Materialize the
aggregator's input columns
+ # once; frames index into them by row.
+ begin_col = concatenated.column(args_offsets[0])
+ end_col = concatenated.column(args_offsets[1])
+ data_offsets = list(args_offsets[2:]) +
list(kwargs_offsets.values())
+ value_cols = [concatenated.column(o).to_pylist() for o
in data_offsets]
+ # When consecutive frames share the same lower bound
and only grow on the
+ # right (e.g. rowsBetween(unboundedPreceding,
currentRow)), extend the
+ # running buffer by the newly-included rows instead of
refolding from
+ # ``zero`` -- O(n) overall rather than O(n^2).
Otherwise -- the lower bound
+ # advanced (a row left the window, which ``reduce``
cannot subtract) or the
+ # frame shrank -- refold the frame from ``zero``.
+ results = []
+ have_running = False
+ running: Any = None
+ prev_begin = -1
+ prev_end = 0
+ for i in range(num_rows):
+ begin = begin_col[i].as_py()
+ end = end_col[i].as_py()
+ if have_running and begin == prev_begin and end >=
prev_end:
+ running = fold(agg, running, value_cols,
prev_end, end)
+ else:
+ running = fold(agg, agg.zero(), value_cols,
begin, end)
+ have_running = True
+ prev_begin, prev_end = begin, end
+ results.append(agg.finish(running))
+ result_arrays.append(pa.array(results,
type=result_type))
+ else:
+ raise PySparkRuntimeError(
+ errorClass="INVALID_WINDOW_BOUND_TYPE",
+ messageParameters={"window_bound_type":
bound_type},
+ )
+
+ batch = pa.RecordBatch.from_arrays(result_arrays, col_names)
+ yield ArrowBatchTransformer.enforce_schema(batch,
return_schema)
+
+ # profiling is not supported for UDF
+ return grouped_func, None, ser, ser
+
if eval_type == PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF:
import pyarrow as pa
import pandas as pd
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
index 6c81d2ab7f52..a477fe4398f6 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
@@ -21,7 +21,6 @@ import org.apache.spark.sql.catalyst.expressions.{
AggregateWindowFunction,
CurrentRow,
FrameLessOffsetWindowFunction,
- PythonAggregate,
RangeFrame,
RankLike,
RowFrame,
@@ -164,15 +163,6 @@ object WindowResolution {
errorClass = "INVALID_WINDOW_SPEC_FOR_AGGREGATION_FUNC",
messageParameters = Map("aggFunc" ->
toSQLExpr(agg.aggregateFunction))
)
- case AggregateExpression(_: PythonAggregate, _, _, _, _) =>
- // The incremental Python aggregator has a dedicated two-stage
physical operator and is not
- // supported as a window function. Reject it here with a clear
analysis error, rather than
- // letting it be misclassified as a SQL aggregate
(`WindowFunctionType.functionType` only
- // routes `PythonUDAF` to the Python path) and fail with an internal
error at execution.
- windowExpression.failAnalysis(
- errorClass = "UNSUPPORTED_EXPR_FOR_WINDOW",
- messageParameters = Map("sqlExpr" ->
toSQLExpr(windowExpression.windowFunction))
- )
case _: AggregateExpression | _: FrameLessOffsetWindowFunction | _:
AggregateWindowFunction =>
case other =>
other.failAnalysis(
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 d62131183c49..b820172bc2b5 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
@@ -240,10 +240,11 @@ object PythonUDF {
}
def isWindowPandasUDF(e: PythonFuncExpression): Boolean = {
- // This is currently only `PythonUDAF` (which means
SQL_GROUPED_AGG_PANDAS_UDF or
- // SQL_GROUPED_AGG_ARROW_UDF), but we might
- // support new types in the future, e.g, N -> N transform.
- e.isInstanceOf[PythonUDAF]
+ // `PythonUDAF` (SQL_GROUPED_AGG_PANDAS_UDF or SQL_GROUPED_AGG_ARROW_UDF)
and the incremental
+ // `PythonAggregate` are the Python aggregate functions that run over a
window through the
+ // Python window operator, rather than the JVM SQL window path. We might
support new types in
+ // the future, e.g. N -> N transform.
+ e.isInstanceOf[PythonUDAF] || e.isInstanceOf[PythonAggregate]
}
def correctEvalType(udf: PythonUDF, pythonUDFArrowFallbackOnUDT: Boolean):
Int = {
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/windowExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/windowExpressions.scala
index f26a4dc9a0c0..e6c52c08e3b1 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/windowExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/windowExpressions.scala
@@ -410,6 +410,9 @@ object WindowFunctionType {
case PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF =>
PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF
case PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF =>
PythonEvalType.SQL_WINDOW_AGG_ARROW_UDF
}
+ // The incremental aggregator has a single window eval type: the window
operator sends each
+ // frame's rows to the worker, which folds them with `reduce` and
produces `finish`.
+ case _: PythonAggregate =>
PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF
}
}
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
index 5db80e86d50c..ada71af43d3b 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
@@ -3029,6 +3029,12 @@ private[sql] object QueryCompilationErrors extends
QueryErrorsBase with Compilat
messageParameters = Map("windowExpressions" ->
windowExpressions.toString()))
}
+ def multiplePythonUDFTypesInWindowError(functionNames: Seq[String]):
Throwable = {
+ new AnalysisException(
+ errorClass = "UNSUPPORTED_FEATURE.MULTIPLE_PYTHON_UDF_TYPES_IN_WINDOW",
+ messageParameters = Map("functionList" ->
functionNames.map(toSQLId).mkString(", ")))
+ }
+
def escapeCharacterInTheMiddleError(pattern: String, char: String):
Throwable = {
new AnalysisException(
errorClass = "INVALID_FORMAT.ESC_IN_THE_MIDDLE",
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
index ab9671c022f9..7a6c90478a80 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonEvaluatorFactory.scala
@@ -26,7 +26,7 @@ import org.apache.spark.{JobArtifactSet, PartitionEvaluator,
PartitionEvaluatorF
import org.apache.spark.api.python.ChainedPythonFunctions
import org.apache.spark.internal.config.Python.PYTHON_UDF_PIPELINED_EXECUTION
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference, BoundReference, EmptyRow, Expression, JoinedRow,
NamedArgumentExpression, NamedExpression, PythonFuncExpression, PythonUDAF,
SortOrder, SpecificInternalRow, UnsafeProjection, UnsafeRow, WindowExpression}
+import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference, BoundReference, EmptyRow, Expression, JoinedRow,
NamedArgumentExpression, NamedExpression, PythonFuncExpression, SortOrder,
SpecificInternalRow, UnsafeProjection, UnsafeRow, WindowExpression}
import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.ExternalAppendOnlyUnsafeRowArray
@@ -156,8 +156,12 @@ class ArrowWindowPythonEvaluatorFactory(
// Extract window expressions and window functions
private val windowExpressions = expressions.flatMap(_.collect { case e:
WindowExpression => e })
+ // The window aggregate function is either a `PythonUDAF` (grouped-agg
pandas/arrow UDF) or the
+ // incremental `PythonAggregate`; both are `PythonFuncExpression`s and
share the per-frame Arrow
+ // window path (only the worker-side per-frame computation and eval type
differ).
private val udfExpressions = windowExpressions.map { e =>
-
e.windowFunction.asInstanceOf[AggregateExpression].aggregateFunction.asInstanceOf[PythonUDAF]
+ e.windowFunction.asInstanceOf[AggregateExpression].aggregateFunction
+ .asInstanceOf[PythonFuncExpression]
}
// We shouldn't be chaining anything here.
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonExec.scala
index dd360bbf230b..92956e4ecb5d 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowWindowPythonExec.scala
@@ -22,6 +22,7 @@ import org.apache.spark.api.python.PythonEvalType
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.execution.SparkPlan
import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
import org.apache.spark.sql.execution.window._
@@ -33,6 +34,7 @@ import org.apache.spark.sql.execution.window._
* <ul>
* <li> SQL_WINDOW_AGG_ARROW_UDF for Arrow UDF
* <li> SQL_WINDOW_AGG_PANDAS_UDF for Pandas UDF
+ * <li> SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF for the incremental Arrow
aggregator (`udaf`)
* </ul>
*
* This is similar to [[WindowExec]]. The main difference is that this node
does not compute
@@ -127,7 +129,8 @@ case class ArrowWindowPythonExec(
private def supportedPythonEvalTypes: Array[Int] =
Array(
PythonEvalType.SQL_WINDOW_AGG_ARROW_UDF,
- PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF)
+ PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF,
+ PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF)
}
object ArrowWindowPythonExec {
@@ -139,8 +142,16 @@ object ArrowWindowPythonExec {
val evalTypes = windowExpression.flatMap(w =>
WindowFunctionType.pythonEvalType(w))
assert(evalTypes.nonEmpty,
"Cannot extract eval type from PythonUDAFs in ArrowWindowPythonExec")
- assert(evalTypes.distinct.size == 1,
- "All window functions must have the same eval type in
ArrowWindowPythonExec")
+ // Distinct eval types here means incompatible Python window UDFs were
grouped into one window
+ // (e.g. an incremental aggregator mixed with a grouped-agg pandas/arrow
UDAF). They share the
+ // `WindowFunctionType.Python` type, so they pass the check in
`PhysicalWindow`, but cannot run
+ // in a single operator; surface a clear analysis error rather than an
internal AssertionError.
+ if (evalTypes.distinct.size != 1) {
+ val functionNames = windowExpression.flatMap(_.collect {
+ case e: PythonFuncExpression => e.name
+ }).distinct
+ throw
QueryCompilationErrors.multiplePythonUDFTypesInWindowError(functionNames)
+ }
ArrowWindowPythonExec(windowExpression, partitionSpec, orderSpec, child,
evalTypes.head)
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]