[SPARK-27052][SQL][PYTHON] Support Python UDFs inside higher-order function lambdas - #57804
Draft
HyukjinKwon wants to merge 4 commits into
Draft
[SPARK-27052][SQL][PYTHON] Support Python UDFs inside higher-order function lambdas#57804HyukjinKwon wants to merge 4 commits into
HyukjinKwon wants to merge 4 commits into
Conversation
…nction lambdas Rewrite scalar Python UDFs that appear inside a higher-order function's lambda so they can be evaluated, instead of failing analysis with UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF. A PythonUDF is evaluated by a separate physical operator that ExtractPythonUDFs pulls out of the enclosing operator, but a lambda's NamedLambdaVariables only exist while the higher-order function iterates, so the UDF can neither stay in the lambda nor be lifted out by the existing rule. The new optimizer rule ExtractPythonUDFFromLambda applies the UDF to the whole array outside every lambda and has the lambda read the precomputed result: transform(values, x -> plus_one(x)) => transform(arrays_zip(values AS c0, plus_one_over_array(values) AS u0), s -> s.u0) The JVM cannot rewrap a pickled Python function, so the array-at-a-time behaviour lives in the worker under a new eval type, SQL_ARROW_ELEMENTWISE_UDF: it flattens the incoming list column, calls the user function once over the concatenated elements of the whole batch, and re-nests the results using the input's offsets. This keeps one row in and one row out (no explode, no shuffle) and crosses the Python boundary once per batch rather than once per row. Co-authored-by: Isaac
…ting
Extends the rewrite to every lambda-taking higher-order function, and to nested
higher-order functions.
Newly supported:
- zip_with: both arrays are projected out of one arrays_zip first, so ragged
arrays are padded to equal per-row length before the positional rewrite.
- array_sort: the UDF becomes a per-element sort key; each comparator side
reads the key of its own element. A comparator whose UDF takes both
elements in one call has no per-element key and is still rejected.
- transform_keys / transform_values / map_filter / map_zip_with: desugared to
the array case over map_keys/map_values and rebuilt with map_from_arrays.
map_zip_with visits the union of both key sets, looking each map up per key.
- aggregate's finish.
- Nested higher-order functions, e.g.
transform(arr, i -> transform(i, x -> f(x))). One pass cannot lift this,
since the inner array is the outer lambda's variable. Rewriting composes
instead: the inner pass lifts f onto i, and because the result sits in the
inner function's *argument* (evaluated outside its lambda) the outer pass
lifts it again onto arr, incrementing the flatten depth. Applied repeatedly
to a fixed point, so arbitrary nesting depth works.
The flatten depth cannot be inferred by the worker: transform(arr, i -> total(i))
and transform(arr, i -> transform(i, x -> f(x))) hand it the identical Arrow type
list<list<int32>> but need one and two flattens respectively. It is therefore
carried explicitly on PythonUDF.elementwiseDepths and sent in the eval conf.
Co-authored-by: Isaac
Replaces the twelve hand-written per-function cases with one generic rewrite, so
that adding a higher-order function does not mean adding rewrite logic.
The generic path never names a concrete class. It reads everything it needs off
the HigherOrderFunction API:
- `arguments` / `functions` give the arity;
- a higher-order function's children are always `arguments ++ functions`
(verified for all twelve), so `withNewChildren` rebuilds any of them;
- lambda parameters map positionally onto the collection arguments, with a
trailing extra parameter being the element index;
- a map-valued argument is desugared to `map_keys`/`map_values` and rebuilt
with `map_from_arrays`.
Two facts per function cannot be derived and stay declarative, defaulting
safely for an unknown function:
- whether the result is the lambda's value or the input elements (the latter
needs the carrier projected back out). Now inferred from the result and
element types, with an explicit list only for the three exceptions.
- whether the lambda is a comparator, which cannot be told from parameter
types alone: `(T, Int)` is ambiguous between a comparator over `array<int>`
and an indexed lambda.
`canRewritePythonUDFInLambda` no longer enumerates functions. It states the two
UDF *placements* no array can precompute -- a UDF on `aggregate`'s accumulator,
and an `array_sort` comparator taking both elements in one call -- plus
`isRewritableShape`, which guards the structural assumption the generic rewrite
makes. Anything else is accepted.
Also fixes ragged multi-array inputs: arguments are projected out of a common
`arrays_zip` first, so `zip_with` over arrays of differing length no longer
misaligns the flattened elements.
Co-authored-by: Isaac
…ewritten Sharpens the negative tests and their reasoning after investigating whether the final two exclusions could be implemented. array_sort with a comparator whose UDF takes both elements: implementable, but only by precomputing the whole n x n matrix of pairs, i.e. O(n^2) Python calls where sorting needs O(n log n) comparisons, plus O(n^2) memory. The supported form -- return a sort key per element -- stays on the O(n) path, so the test now says that explicitly and points at it. A UDF reading aggregate's accumulator: not implementable at any cost, which is a stronger statement than "expensive". Every other shape precomputes over values that are elements of some collection. Here they are not: folding [1,2,3] with `acc*2 + x` calls the UDF on 0, 1, 4 -- outputs of earlier steps, which do not exist until the fold runs. So there is no input set to precompute over, and even the cross-product trick that would make a pairwise comparator work does not apply. Supporting it would need either a physical operator that calls Python from inside higher-order function evaluation, which the extraction architecture does not allow, or unrolling the fold, which needs a static bound on array length. Co-authored-by: Isaac
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
works with a plain
pyspark.sql.functions.udfinstead of failing with[UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF].Why it does not work today. A
PythonUDFis evaluated by a separate physical operator(
ArrowEvalPython), whichExtractPythonUDFspulls out of the enclosing operator. A lambda'sNamedLambdaVariables only exist while the higher-order function is iterating, so an extractedoperator cannot see them: the UDF can neither stay inside the lambda nor be lifted out by the
existing extraction rule.
CheckAnalysistherefore 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:It runs in the
Extract Python UDFsbatch beforeExtractPythonUDFs, which then extracts thelifted UDF as an ordinary top-level
PythonUDF, so no new physical operator is needed. It is innonExcludableRules, since a plan that only works because of this rewrite must not be silentlybroken by
spark.sql.optimizer.excludedRules.Where the array-at-a-time behaviour lives.
_wrap_functionpickles(func, returnType)asopaque 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 incominglist 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. The Arrow
boundary is crossed once per batch rather than once per row, and it stays one row in / one row out:
no
explode, no shuffle.All twelve lambda-taking functions are supported:
transform,filter,exists,forall,zip_with,aggregate/reduce(both onmerge's element and infinish),array_sort(as aper-element sort key),
transform_keys,transform_values,map_filterandmap_zip_with. Themap family is desugared to the array case over
map_keys/map_valuesand rebuilt withmap_from_arrays;map_zip_withvisits the union of both key sets, looking each map up per key soa key missing from one side yields null, matching its own semantics.
Nested higher-order functions are supported, e.g.
transform(arr, i -> transform(i, x -> f(x))). One pass cannot lift this, because the inner arrayis the outer lambda's variable. The rewrite composes instead: the inner pass lifts
fontoi, andbecause the result then sits in the inner function's argument — which is evaluated outside its
lambda — the outer pass lifts it again onto
arr, incrementing the flatten depth. Appliedrepeatedly to a fixed point, so arbitrary nesting depth works (tested three deep, and with mixed
kinds such as a
filterinside atransform).The flatten depth cannot be inferred by the worker:
transform(arr, i -> total(i))andtransform(arr, i -> transform(i, x -> f(x)))hand it the identical Arrow typelist<list<int32>>but need one and two flattens respectively. It is therefore carried explicitly on
PythonUDF.elementwiseDepthsand sent in the eval conf.The rule is generic. Rather than a case per function, there is one rewrite that reads what it
needs off the
HigherOrderFunctionAPI —arguments/functionsfor the arity,withNewChildrento rebuild (a higher-order function's children are always
arguments ++ functions, verified for alltwelve), and positional matching of lambda parameters to collection arguments. Only two facts per
function cannot be derived, and both default safely for an unknown function: whether the result is
the lambda's value or the input elements, and whether the lambda is a comparator (which parameter
types cannot reveal, since
(T, Int)is ambiguous). Adding a new higher-order function of afamiliar shape needs no change to the rule.
Still rejected (
CheckAnalysisand the rule share one predicate, so analysis accepts exactlywhat the optimizer rewrites):
aggregate's accumulator. Not rewritable at any cost, which is stronger than"expensive". Every other shape precomputes over values that are elements of some collection; here
they are not. Folding
[1,2,3]withacc*2 + xcalls the UDF on0, 1, 4— outputs of earliersteps, which do not exist until the fold runs. So there is no input set to precompute over, and
even the cross-product trick that would make a pairwise comparator work does not apply.
Supporting it would need either a physical operator that calls Python from inside higher-order
function evaluation (which the extraction architecture does not allow) or unrolling the fold
(which needs a static bound on array length).
array_sortcomparator whose UDF receives both elements in one call. This one isimplementable, by precomputing the whole n×n matrix of pairs and having the comparator index it —
but that is O(n²) Python calls where sorting needs only O(n log n) comparisons, plus O(n²) memory.
Returning a per-element sort key instead stays on the O(n) path and is supported, so the error
points there. I prototyped the matrix rewrite and left it out rather than ship a pessimisation.
Seriesrather than one value per call.The rewrite can be turned off with
spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled=false, restoring the previous error.This builds on design notes derived from the
elementwise-udf prototype (Apache-2.0), which
established which rewrites are correct and which are not worth doing. Note the Catalyst
implementation goes further: the prototype cannot support a higher-order function nested inside a
lambda, because it resolves each argument with
F.col(...)outside the lambda.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 arraysbecause carrying the source array alongside
posexplodeduplicates it per element.Does this PR introduce any user-facing change?
Yes. A query that previously failed analysis now runs:
The two 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(41 tests), whichasserts 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 all twelve
higher-order functions; nested higher-order functions (two and three deep, and mixed kinds);
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-nullrows and an empty frame; ragged
zip_withinputs; long arrays over many rows to exercise batching;integration with joins, caching and
groupBy; mixing with an ordinary Python UDF; that a lambdawith no Python UDF is left untouched; and the negative cases above.
New plan-shape suite
ExtractPythonUDFFromLambdaSuite(15 tests) asserts that noPythonUDFremains inside a
LambdaFunctionfor any of the twelve functions, that the lifted UDF is anelement-wise UDF over an array with the right flatten depth, that duplicate calls are evaluated
once, that the rule is inert without a UDF, and that it is not excludable.
PythonUDFSuite'sSPARK-48706negative test asserted the old behaviour for exactly the case nowsupported; it is updated to assert the result and a still-unsupported shape. The
execution.pythonsuites,
DataFrameFunctionsSuite,pyspark.sql.tests.test_udfandarrow.test_arrow_python_udfpass.
The Arrow flatten/re-nest algorithm was also validated standalone against null arrays, null
elements, empty arrays, sliced arrays, large lists, two-level nesting and broadcast arguments before
being wired in.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)