[SPARK-58736][SQL][PYTHON][CONNECT] Support incremental Python aggregators via Arrow - #57952
[SPARK-58736][SQL][PYTHON][CONNECT] Support incremental Python aggregators via Arrow#57952HyukjinKwon wants to merge 8 commits into
Conversation
Add a Python analog of the Scala typed `Aggregator[IN, BUF, OUT]` with true
incremental (partial) aggregation. Users subclass `Aggregator`
(`zero`/`reduce`/`merge`/`finish` + `bufferSchema`) and wrap it with
`arrow_udaf(...)` for use in `groupBy().agg(...)`.
Unlike grouped-agg pandas/arrow UDFs (whole-group materialization), this is
planned as a two-stage aggregation with map-side combine: a PARTIAL stage folds
each group's input rows into a per-group Arrow buffer via `reduce`, the buffers
are shuffled by the grouping key, and a FINAL stage merges the partial buffers
via `merge` and produces the output via `finish`.
- New eval types SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL/FINAL_UDF
(Python `PythonEvalType` and JVM `PythonEvalType`).
- New Catalyst expression `PythonAggregate` carrying the intermediate buffer
schema (unevaluable in the JVM, like `PythonUDAF`).
- New physical operators `PythonIncrementalAggregate{Partial,Final}Exec`,
routed in `SparkStrategies` as Partial -> Exchange -> Final; the buffer
crosses the shuffle as an Arrow struct column.
- Worker handlers: reduce-into-buffer (partial) and merge+finish (final).
- `arrow_udaf` / `Aggregator` API under `pyspark.sql.pandas.aggregator`.
Buffer schema is threaded to the JVM via a new nullable `bufferType` on
`UserDefinedPythonFunction`. Out of scope for now (follow-ups): distinct,
mixing with SQL aggregates, window/streaming, Spark Connect, SQL registration,
and a typed-vs-pickled buffer perf variant.
Co-authored-by: Isaac
…ark Connect Wire the incremental Python aggregator (arrow_udaf / Aggregator) through Spark Connect so it works in remote sessions as well as classic. - Proto: add optional `buffer_type` (DataType) to the `PythonUDF` message and regenerate the Python stubs. - Connect client: `PythonUDF` expression wrapper carries `buffer_type` and serializes it into the proto; `UserDefinedFunction` forwards a `bufferSchema` attribute. `arrow_udaf` now dispatches on `is_remote()` to build the Connect UDF in a remote session. - Connect server: `SparkConnectPlanner.createUserDefinedPythonFunction` threads `buffer_type` into `UserDefinedPythonFunction`, and `transformPythonFuncExpression` builds `PythonAggregate` for the incremental eval type. Execution then reuses the same operators/worker code as classic. - Test: `ArrowPythonAggregatorParityTests` runs the same mixin under `ReusedConnectTestCase`. Co-authored-by: Isaac
Name the factory `udaf` to mirror Scala's `functions.udaf(agg)`, and require a supported PyArrow version up front (via require_minimum_pyarrow_version) with a clear error, since the aggregator transfers its intermediate buffer as Arrow. Co-authored-by: Isaac
…o 4.4.0 Relocate `aggregator.py` from `pyspark.sql.pandas` to `pyspark.sql` (import as `pyspark.sql.aggregator`), and set the `versionadded` for `Aggregator`/`udaf` to 4.4.0. Update the references in util.py, connect/udf.py, and the test. Co-authored-by: Isaac
… aggregator Allow `spark.udf.register(name, udaf(agg))` so the incremental Python aggregator can be invoked from SQL text (`SELECT my_agg(v) FROM t GROUP BY k`), matching Scala's `spark.udf.register(name, functions.udaf(agg))`. - Classic and Connect `UDFRegistration.register` accept SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF and thread the buffer schema through (classic `register` reconstructs the UDF and would otherwise drop it; Connect passes it via `SparkConnectClient.register_udf` -> the PythonUDF proto). - `udaf` sets `bufferSchema` on the returned wrapper too, so it survives registration. The Connect server already builds `PythonAggregate` in `handleRegisterUserDefinedFunction` via the shared `createUserDefinedPythonFunction`. - Test: `test_sql_registration` in the shared mixin (runs classic + Connect). Co-authored-by: Isaac
|
cc @zhengruifeng @cloud-fan @Yicong-Huang Seems like this way it can do the actual partial aggregation. |
| # profiling is not supported for UDF | ||
| return grouped_func, None, ser, ser | ||
|
|
||
| if eval_type == PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF: |
There was a problem hiding this comment.
why can't this reuse PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF?
There was a problem hiding this comment.
I think it is a different iterator? it is for element-iterator inside a row, not a row-iterator
There was a problem hiding this comment.
yeah my take is that PythonEvalType.* decides internal computation type
|
- Use PySparkNotImplementedError instead of a raw NotImplementedError in Aggregator.__call__ (PySpark custom-errors linter). - Import have_pyarrow / pyarrow_requirement_message from pyspark.testing.utils (not sqlutils), which was causing the aggregator test modules to fail at import. Co-authored-by: Isaac
cloud-fan
left a comment
There was a problem hiding this comment.
2 blocking, 0 non-blocking, 0 nits.
The two-stage integration is coherent, but the worker lifecycle still has two blocking semantic gaps in incremental memory use and empty-input aggregation.
Design / architecture (1)
- Blocking: python/pyspark/worker.py:2234: Stream partial input batches into the aggregation buffers instead of retaining and concatenating the complete group first. -- see inline
Correctness (1)
- Blocking: sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonIncrementalAggregateExec.scala:126: Emit the identity buffer for empty global input so
finish(zero)produces the required single aggregate row. -- see inline
Verification
Traced udaf through classic and Connect UDF construction, Catalyst planning, both physical stages, and the Python worker handlers. Confirmed that the partial handler calls list(group) before reduce, and that the physical operator returns an empty iterator before the identity buffer can be emitted for empty global input. Tests were not run as part of this review.
| split_index: int, data: Iterator["GroupedBatch"] | ||
| ) -> Iterator[pa.RecordBatch]: | ||
| for group in data: | ||
| batch_list = list(group) |
There was a problem hiding this comment.
Blocking:
The partial stage still materializes every Arrow batch for the group before reduce runs, so a skewed group retains the same whole-group peak memory that this API is meant to avoid. Please fold each incoming batch directly into the per-aggregator buffers and retain only those buffers.
There was a problem hiding this comment.
Fixed in a12f825. The PARTIAL (and FINAL) worker handlers now stream Arrow batches and fold each one directly into the per-aggregator buffers — no more list(group)/concat_batches. Map-side peak memory is now bounded by a single batch plus the buffers, not the whole group.
| val resultExprs = outputExpressions | ||
| val localEvalType = evalType | ||
|
|
||
| inputRDD.mapPartitionsInternal { iter => if (iter.isEmpty) iter else { |
There was a problem hiding this comment.
Blocking:
This shortcut drops empty global aggregations before Python can apply zero and finish, so df.limit(0).agg(udaf(...)) returns no row instead of the aggregate's identity result. Please emit an identity partial buffer for the no-group empty-input case and cover it with a focused test.
There was a problem hiding this comment.
Fixed in a12f825. A global aggregation over empty input now returns the identity row finish(zero). Since GroupedPythonArrowInput can't transmit an empty group, the FINAL stage (single AllTuples partition) injects one all-null buffer row; the worker skips null partial buffers, so it merges nothing and emits finish(zero). Added a focused test: df.limit(0).agg(udaf(...)).
…obal aggregation Two blocking review items: - PARTIAL/FINAL worker handlers now stream Arrow batches and fold them one at a time into the per-aggregator buffers, instead of `list(group)` + concatenating the whole group first. Map-side peak memory is bounded by a single batch (plus the buffers), not the whole group -- the point of the incremental API. - A global (no-grouping) aggregation over empty input now returns the identity row `finish(zero)` instead of no row. GroupedPythonArrowInput cannot transmit an empty group, so the FINAL stage (which runs on a single AllTuples partition) injects one all-null buffer row; the worker skips null partial buffers and so merges nothing, yielding `finish(zero)`. Added a focused test (`df.limit(0).agg(udaf(...))`). Co-authored-by: Isaac
- invalidPandasUDFPlacementError now also names incremental PythonAggregate functions (not just grouped-agg PythonUDAF) when Python aggregate UDFs are mixed with other aggregate functions in one Aggregate. - Add a test with two incremental aggregators (different buffer schemas) over the same input, covering multi-UDF partial/final planning and execution. Co-authored-by: Isaac
|
Thanks for taking a look — happy to hold. Answering the three points: 1. Physical operator — it already is one implementation. Both stages run through a single 2. What "blocks" collapsing the two eval types. They encode two genuinely different worker
On the JVM the mode lives on 3. Multiple UDFs and mixing.
Latest push (3917089) has the multi-UDF test and the error-message fix. Let me know which shape you |
What changes were proposed in this pull request?
Adds a Python analog of the Scala typed
org.apache.spark.sql.expressions.Aggregator[IN, BUF, OUT]with true incremental (partial) aggregation — i.e. map-side combine, not whole-group
materialization.
Users subclass a new
Aggregatorbase class (zero/reduce/merge/finish+bufferSchema) and wrap it withudaf(...)for use ingroupBy().agg(...):Unlike grouped-agg pandas/arrow UDFs (
PythonUDAF+ArrowAggregatePythonExec), which collect thewhole group and call Python once, this is planned as a two-stage aggregation:
reduce;mergeand produces the output viafinish.Because
mergeis associative/commutative, the result is independent of partition count.Class hierarchy / trace:
PythonEvalType: newSQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF(255) and..._FINAL_UDF(256), added on both the Python (
pyspark.util) and JVM (api.python.PythonEvalType) sides.PythonAggregateexpression (anUnevaluableAggregateFunc, likePythonUDAF)carrying the intermediate
bufferSchema.SparkStrategies.Aggregationroutes an all-PythonAggregateaggregate toPythonIncrementalAggregateExec.plan(...), which buildsPythonIncrementalAggregatePartialExec-> (Exchange, inserted byEnsureRequirements) ->PythonIncrementalAggregateFinalExec. Both operators reuseArrowPythonWithNamedArgumentRunnerGroupedPythonArrowInput.worker.py): a PARTIAL handler that folds input batches into a buffer viareduce, anda FINAL handler that merges partial-buffer rows via
mergethenfinish.bufferTypeonUserDefinedPythonFunction(an auxiliary constructor preserves the existing Py4J arity).Spark Connect: also supported. A new optional
buffer_typefield on thePythonUDFprotomessage carries the buffer schema to the server; the Connect client (
connect/udf.py,connect/expressions.py) serializes it andudafdispatches onis_remote(); the serverSparkConnectPlannerthreadsbuffer_typeintoUserDefinedPythonFunctionand buildsPythonAggregate, after which execution reuses the same operators/worker code as classic.SQL registration:
spark.udf.register("my_agg", udaf(agg))works in both classic and Connect,so the aggregator is usable from SQL text (
SELECT my_agg(v) FROM t GROUP BY k) — the counterpartof Scala's
spark.udf.register(name, functions.udaf(agg)).Out of scope (planned follow-ups):
DISTINCT, mixing with SQL aggregate functions inone
Aggregate, window/streaming, real disk spill (currently the map side bounds memory byper-partition grouping; associativity makes early partial emission safe), and a typed-columnar vs.
pickled buffer performance variant.
Why are the changes needed?
PySpark has no incremental user-defined aggregator: every custom-aggregation path (grouped-agg
pandas_udf/arrow_udf,applyInPandas) materializes the whole group and invokes Python once,with no map-side combine or partial/merge across the shuffle. This adds the missing
Aggregator-style abstraction with genuine partial aggregation, matching the Scala typedAggregator.Does this PR introduce any user-facing change?
Yes — a new public API:
pyspark.sql.aggregator.Aggregatorandudaf(...), usable ingroupBy().agg(...)and registrable viaspark.udf.register(...)for use in SQL. No existingbehavior changes.
How was this patch tested?
sql/compile(catalyst + core + sql) builds cleanly with the newexpression, operators, and planner routing.
python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py(
ArrowPythonAggregatorTests): checks the incremental aggregator matches built-inavg/sum,a no-group case, a custom buffer, and that results are independent of partition count (exercising
partial + merge), plus
test_sql_registration(register viaspark.udf.register, invoke fromSQL text). A Connect parity suite (
ArrowPythonAggregatorParityTests) runs the same mixinunder
ReusedConnectTestCase.sql/compileandconnect/compilebuildcleanly (including proto regeneration). Full test execution runs in this PR's CI.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)