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

   ### What changes were proposed in this pull request?
   
   Widens the eval types the Python UDF transpiler may rewrite, from the pickled
   `SQL_BATCHED_UDF` alone to also cover `SQL_ARROW_BATCHED_UDF` (101) and
   `SQL_SCALAR_PANDAS_UDF` (200). The two are admitted for different reasons, 
and that
   asymmetry is the substance of the change.
   
   `SQL_ARROW_BATCHED_UDF` is not really vectorized -- Arrow is only the 
transport, and the
   worker still calls the function once per row with Python scalars. Its 
Python-level
   semantics are exactly what the transpiler already reproduces, so every 
existing lowering
   applies unchanged.
   
   `SQL_SCALAR_PANDAS_UDF` is genuinely vectorized (one `pandas.Series` per 
argument, a
   Series required back), so most scalar lowerings do **not** carry over. Only 
an
   element-wise subset is rewritten, chosen by a strict allowlist checked 
before any
   lowering runs: parameter references, int/float/str constants, `+` `-` `*` 
`%`, unary
   `+`/`-`, and `<param>.isnull()` (plus the `.isna()` / `.notnull()` / 
`.notna()` aliases).
   Everything else falls back to interpreted Python. Each exclusion is a 
construct whose
   scalar lowering would be actively wrong on a Series -- `if s:` / `s and t` / 
`not s`
   raise `ValueError`, `s is None` is always False, `s < 0` treats a missing 
value as False
   rather than NULL or TypeError, and a body that never references a parameter 
returns a
   scalar the interpreted path rejects. Comparisons are therefore out of scope; 
they need
   their own NaN-aware lowering and are left as follow-up work.
   
   Two pandas value rules are reproduced explicitly: `Series.isnull()` lowers to
   `isnull(a) OR isnan(a)` (Arrow-to-pandas conversion has already collapsed a 
NULL in a
   fractional column into NaN), and a numeric body is wrapped in an 
isnan-to-NULL
   normalization (a numpy-backed result Series is masked with `series.isnull()` 
on the way
   out). That mask is conditional, which bounds validity: with 
`preferIntExtensionDtype=true`
   the Series is a pandas masked extension array, the mask is skipped, and 
normalizing NaN
   to NULL would be backwards -- so pandas transpilation is refused when that 
conf is on.
   Transpilation is likewise refused for an Arrow-optimized UDF under the 
legacy pandas
   conversion. Both confs can be flipped after the UDF is built, so 
`ConvertToCatalyst`
   re-checks them the way it already re-checks the ANSI gate.
   
   The eval-type set is expressed as membership rather than negation, so a 
newly added eval
   type must be admitted consciously instead of inheriting transpilation by 
failing to match
   an exclusion; a canary over every `PythonEvalType` fails until a new one is 
triaged.
   
   ### Why are the changes needed?
   
   Arrow optimization is the default for regular Python UDFs, so before this the
   transpilation flag did nothing on default configuration -- the feature was 
effectively
   unreachable without opting out of the default execution path. Extending to 
scalar pandas
   UDFs then covers the other commonly used scalar shape, conservatively.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes, when Python UDF transpilation is enabled (it is off by default). 
Arrow-optimized and
   scalar pandas UDFs that previously always ran as interpreted Python may now 
be rewritten
   into Catalyst expressions. Anything outside the supported subset falls back 
to interpreted
   Python, so behavior is unchanged there.
   
   Known divergences for pandas are numpy's fixed-width arithmetic, documented 
in
   `transpile.py` and `SQLConf` and pinned by tests with the actual values: 
integer overflow
   wraps, float32 survives a float literal (NEP 50), an integral column becomes 
float64 as
   soon as a batch contains a NULL, and `a % 0` yields a missing value (or 0 
under the
   extension dtype). In each case the transpiled expression is the exact or 
ANSI-correct one.
   
   ### How was this patch tested?
   
   Targeted suites per eval type, split into differentials over the supported 
subset,
   refusal tests asserting behavior is unchanged (including that the raising 
bodies still
   raise), and divergence tests pinning the known differences. Plus an 
eval-type canary and
   a data-type canary that fail until a new type is triaged, and parity runs of 
the existing
   Arrow and scalar-pandas UDF mixins with the rewrite active. Both sides of 
every
   differential use the same eval type, since the pickled, Arrow and pandas 
paths coerce
   return values differently.
   
   Run locally rebased on current master, JDK 21, all green:
   
   | Suite | Result |
   |---|---|
   | `arrow.test_arrow_python_udf_transpile` | 47/47 |
   | `arrow.test_arrow_python_udf_transpile_type_canary` | 14/14 |
   | `arrow.test_arrow_python_udf_transpile_parity` | 307 OK (11 skipped) |
   | `pandas.test_pandas_udf_transpile` | 42/42 |
   | `pandas.test_pandas_udf_transpile_parity` | 81 OK (1 skipped) |
   | `test_udf_transpile_unit` | 83/83 |
   | `test_udf_transpile_parity` | 114 OK |
   | `test_udf_transpile_hypothesis` | 23/23 (`RUN_HYPOTHESIS=1`, 200 examples) 
|
   | `test_udf` | 95/95 |
   | catalyst `Optimizer*` suites + `ConvertToCatalystSuite` | all passed 
(40/40) |
   
   Two suites needed updating for changes that landed on master in the 
meantime, in
   a separate commit:
   
   - `test_middle_of_arrow_udf_chain_is_not_elided` counted Arrow operators and
     expected two. Master now pipelines a UDF chain into one 
`ArrowEvalPythonExec`,
     so the operator count is 1 whether or not the middle call was lowered. It 
now
     counts `PythonUDF` expression nodes, which still distinguishes the two, 
with a
     contrast case asserted alongside so it cannot pass by nothing being 
lowered.
   - The eval-type/data-type canary went red because it was working: master 
added
     `TimestampLTZNanosType` and `TimestampNTZNanosType`, and the canary fails 
until
     a new type is triaged. Both are `NO_TRANSPILE` as input and return type, 
like
     every other timestamp.
   
   Transpilation is non-Connect only by design, so the parity suites are 
guarded with
   `is_remote_only()` and are intentionally not inherited into the Spark 
Connect parity
   tests; there is no Connect-side parity to run for this change.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Opus 5
   Generated-by: GLM 5.2 (Cursor)
   <!-- This is an auto-generated comment: release notes by coderabbit.ai -->
   <!-- coderabbit-ai-generated:v1 -->
   
   ## Summary by CodeRabbit
   
   * **New Features**
     * Experimental transpilation now supports regular Python UDFs, including 
Arrow-optimized UDFs, and scalar pandas UDFs that use supported element-wise 
operations.
     * Eligible UDFs can use transpilation when the required settings are 
enabled. Unsupported patterns and incompatible conversion settings continue to 
use interpreted Python execution.
   
   * **Documentation**
     * Updated configuration guidance describes eligible UDF types, supported 
operations, numerical differences, and conditions that use interpreted 
execution.
   <!-- end of auto-generated comment: release notes by coderabbit.ai -->
   
   


-- 
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