HyukjinKwon opened a new pull request, #57804:
URL: https://github.com/apache/spark/pull/57804

   ### What changes were proposed in this pull request?
   
   This PR lets a scalar Python UDF be used inside the lambda of a higher-order 
function, so that
   
   ```python
   df.select(F.transform("values", lambda x: plus_one(x)))
   ```
   
   works with a plain `pyspark.sql.functions.udf` instead of failing with
   `[UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF]`.
   
   **Why it does not work today.** A `PythonUDF` is evaluated by a separate 
physical operator
   (`ArrowEvalPython`), which `ExtractPythonUDFs` pulls out of the enclosing 
operator. A lambda's
   `NamedLambdaVariable`s only exist while the higher-order function is 
iterating, so an extracted
   operator cannot see them: the UDF can neither stay inside the lambda nor be 
lifted out by the
   existing extraction rule. `CheckAnalysis` therefore rejected it outright.
   
   **The rewrite.** Do not evaluate the UDF per element inside the lambda. 
Apply it once to the
   *whole array*, outside every lambda, and let the lambda read the precomputed 
result positionally.
   A new optimizer rule, `ExtractPythonUDFFromLambda`, does this:
   
   ```
   -- before (rejected)
   transform(values, x -> plus_one(x))
   
   -- after (legal: the PythonUDF is outside every LambdaFunction)
   transform(arrays_zip(values AS c0, plus_one_over_array(values) AS u0), s -> 
s.u0)
   ```
   
   It runs in the `Extract Python UDFs` batch *before* `ExtractPythonUDFs`, 
which then extracts the
   lifted UDF as an ordinary top-level `PythonUDF`, so no new physical operator 
is needed. It is
   registered in `nonExcludableRules`, since a plan that only works because of 
this rewrite must not
   be silently broken by `spark.sql.optimizer.excludedRules`.
   
   **Where the array-at-a-time behaviour lives.** `_wrap_function` pickles 
`(func, returnType)` as
   opaque bytes, so the JVM can never rewrap the user's function; the wrapper 
has to be worker-side.
   A new eval type, `SQL_ARROW_ELEMENTWISE_UDF`, does it with Arrow: the worker 
flattens the incoming
   list column, calls the user function once over the concatenated elements of 
the **whole batch**,
   then re-nests the results using the input's offsets plus an explicit 
validity mask.
   
   This is deliberately stronger than a per-row Python loop. The number of 
Python-level calls is
   proportional to the number of elements, but the Arrow boundary is crossed 
once per batch rather
   than once per row, which the design notes measured as the dominant cost. It 
also keeps one row in
   and one row out: no `explode`, no shuffle.
   
   Once the UDF result is an ordinary column, everything the lambda does around 
it is ordinary JVM
   work, so arithmetic, `when`, casts, the element index, several UDFs in one 
lambda, nested UDF
   calls (`f(g(x))`) and UDF arguments that are expressions over the element 
all follow without
   special cases.
   
   **Supported:** `transform`, `filter`, `exists`, `forall`, and `aggregate` 
when the UDF applies to
   the element.
   
   **Still rejected** (`CheckAnalysis` and the rule share one predicate, so 
analysis accepts exactly
   what the optimizer rewrites):
   
   - a UDF reading `aggregate`'s accumulator, which is sequential (step *n* 
depends on *n-1*);
   - a UDF in `aggregate`'s `finish` — it runs once on the final accumulator, 
and lifting it out
     would call it on the null a fold over a null array produces, where native 
Spark does not
     evaluate `finish` at all;
   - a pandas UDF, which receives a `Series` rather than one value per call;
   - a nested higher-order function, e.g. `transform(arr, i -> transform(i, x 
-> udf(x)))`: the inner
     array only exists while the outer function iterates, so a UDF lifted onto 
it would still sit
     inside the outer lambda. (Note the reference prototype does not support 
this shape either — its
     "nested" support is nested *UDF calls*, which this PR does support. 
Extending to nested
     higher-order functions is possible by flattening and re-nesting one level 
deeper, and is left
     as follow-up.)
   - the map family (`transform_keys`, `transform_values`, `map_filter`, 
`map_zip_with`) and
     `zip_with`, left as follow-up.
   
   The rewrite can be turned off with
   `spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled=false`, 
restoring the previous error.
   
   This builds on the design notes derived from the
   [elementwise-udf](https://github.com/HyukjinKwon/elementwise-udf) prototype 
(Apache-2.0), which
   established which rewrites are correct and which are not worth doing.
   
   ### Why are the changes needed?
   
   `transform(col, lambda x: my_udf(x))` is a natural thing to write and is a 
long-standing gap
   (SPARK-27052). Today users must either avoid Python UDFs in lambdas entirely 
or fall back to
   `explode` + regroup, which the prototype measured at 2-40x slower and which 
OOMs on long arrays
   because carrying the source array alongside `posexplode` duplicates it per 
element.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes. A query that previously failed analysis now runs:
   
   ```python
   >>> plus_one = udf(lambda x: x + 1, "int")
   >>> df.select(F.transform("values", lambda x: plus_one(x))).show()
   # before: AnalysisException 
[UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF]
   # after:  [2, 3, 4]
   ```
   
   Shapes that cannot be rewritten keep failing with the same error condition 
as before. No existing
   successful query changes behaviour.
   
   ### How was this patch tested?
   
   New end-to-end suite `pyspark.sql.tests.test_udf_in_higher_order_function` 
(29 tests, all
   passing), which asserts results against the equivalent native expression 
wherever one exists, so a
   rewrite that runs but computes the wrong thing fails rather than passing 
quietly. It covers each
   supported higher-order function; composition around the UDF result; the 
index parameter; several
   and nested UDF calls; UDF arguments that are expressions over the element; 
broadcast outer-column
   and constant-only arguments (the constant case must still yield one result 
per element); element
   and return types including string, double and `array<int>`; null arrays, 
null elements, UDFs
   returning null, empty arrays, all-null rows and an empty frame; long arrays 
over many rows to
   exercise batching; integration with joins, caching and `groupBy`; mixing 
with an ordinary Python
   UDF; that a lambda with no Python UDF is left untouched; and the negative 
cases above.
   
   New plan-shape suite `ExtractPythonUDFFromLambdaSuite` (13 tests, all 
passing) asserts that no
   `PythonUDF` remains inside a `LambdaFunction`, that the lifted UDF is an 
element-wise UDF over an
   array, that duplicate calls are evaluated once, that the rule is inert 
without a UDF, and that it
   is not excludable.
   
   `PythonUDFSuite`'s `SPARK-48706` negative test asserted the old behaviour 
for exactly the case now
   supported; it is updated to assert the result and a still-unsupported shape. 
`PythonUDFSuite`
   (16 tests), the `execution.python` suites, `DataFrameFunctionsSuite`, 
`pyspark.sql.tests.test_udf`
   and `arrow.test_arrow_python_udf` pass.
   
   The Arrow flatten/re-nest algorithm was also validated standalone against 
null arrays, null
   elements, empty arrays, sliced arrays, large lists and broadcast arguments 
before being wired in.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Code (Opus 5)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to