spark-test commented on code in PR #59898:
URL: https://github.com/apache/spark/pull/59898#discussion_r4408012971
##########
python/pyspark/sql/transpile.py:
##########
@@ -372,31 +405,56 @@ def _lower_eq(
where Python's ``==`` is simply False. Refuse those so the UDF falls
back to interpreted Python. A ``None`` literal operand stays allowed
(the four-branch NULL handling above reproduces Python exactly).
+ Numeric sub-categories (``"integer"`` vs ``"float"``) are treated as
+ compatible; Spark widens to double in the comparison.
+
+ A NaN guard is added unless both operands are provably ``"integer"``:
+ ``NaN == NaN`` is ``False`` in Python (IEEE 754), but Spark's
+ ``EqualTo`` returns ``True``. ``"numeric"`` is an unknown sub-type
+ that may resolve to a float column, so it keeps the guard.
- One value-level difference remains (needs runtime values, so it is
- documented, not guarded): Spark treats ``NaN = NaN`` as true, while
- Python's ``nan == nan`` is False.
+ One value-level difference remains for ordering comparisons (tracked
+ separately): Spark orders NaN as greater than every value, whereas
+ Python's NaN comparisons are all False.
"""
lc = self._safe_category(params, left_node)
rc = self._safe_category(params, right_node)
if lc is not None and rc is not None and lc != rc:
- raise UnsupportedOperationException(
- f"`==`/`!=` operands have incompatible categories ({lc} vs
{rc}); "
- "Python compares across types as unequal while Spark would
coerce "
- "or fail analysis, so the transpiler falls back to interpreted
Python"
- )
+ # Numeric sub-categories are mutually compatible for equality.
+ if not (_is_numeric_cat(lc) and _is_numeric_cat(rc)):
+ raise UnsupportedOperationException(
+ f"`==`/`!=` operands have incompatible categories ({lc} vs
{rc}); "
+ "Python compares across types as unequal while Spark would
coerce "
+ "or fail analysis, so the transpiler falls back to
interpreted Python"
+ )
left_col = self._convert_chunk(params, left_node)
right_col = self._convert_chunk(params, right_node)
left_null = left_col.isNull()
right_null = right_col.isNull()
+ # NaN guard: Python's `NaN == NaN` is False (IEEE 754 reflexivity
fails),
+ # but Spark's EqualTo returns True. Emit only for numeric operands, and
+ # skip only when both are provably integral ("integer"); "numeric" is
an
+ # unknown sub-type that may resolve to a float column, so it keeps the
guard.
+ if (
+ lc is not None
+ and rc is not None
+ and _is_numeric_cat(lc)
+ and _is_numeric_cat(rc)
+ and not (lc == "integer" and rc == "integer")
+ ):
+ nan_result = lit(not equal)
+ nan_cmp = isnan(left_col) | isnan(right_col)
Review Comment:
**Non-blocking (P2):** This guard trusts `_safe_category`, but `"numeric"`
is also `_category_uncached`'s catch-all (lines 635-639), and that function has
no `ast.Compare` arm. A comparison is labeled `"bool"` only when
`_is_definitely_boolean` accepts it, which requires basic operands, so `len(s)
> 0`, `(a if c else b) > 0` or a nested comparison is labeled `"numeric"` even
though `_lower_value_compare` lowers it to a BOOLEAN column.
For example, with transpilation and ANSI on, `def f(s: str, t: str): return
(len(s) > 0) == (len(t) > 0)` registered as `udf(f, "int")` over two string
columns passes the return-type check as numeric, keeps its `[string, string]`
option, and now emits `isnan(<boolean>)`. `IsNaN` accepts only DOUBLE/FLOAT
(`nullExpressions.scala` 394-397), and ANSI implicit casting finds no
BOOLEAN-to-DOUBLE cast (`Cast.canANSIStoreAssign` returns false). Because
options are children of `TranspiledPythonUDF`, CheckAnalysis rejects the whole
query with a DATATYPE_MISMATCH on an `isnan` the user never wrote, instead of
falling back to Python. On branch-4.x before this change the same option was
`EqualTo(boolean, boolean)` cast to int, which analyzes. The trigger is narrow
(compound comparisons on both sides of `==`/`!=` with a numeric declared return
type), but it is exactly the whole-query failure the fallback design exists to
prevent.
**Recommended change:** Give every comparison node the boolean category in
the transpiler's category inference, ahead of the numeric catch-all, so
compound comparisons are labeled as the booleans they lower to. Add Python UDF
transpilation tests for equality between compound comparisons.
**Why this works:** Every comparison the transpiler lowers produces a
BOOLEAN column: is/is not None, ==, !=, <, <=, > and >=. Once such nodes are
labeled boolean, the NaN guard, which fires only when both operands are labeled
numeric, no longer sees them. A boolean-versus-boolean equality lowers to a
plain equality, and the existing category checks treat these nodes as booleans
for arithmetic, truthiness, branch compatibility and the return-type match.
**Scope:** Category inference for comparison nodes in the Python UDF
transpiler, plus regression tests for equality between compound comparisons.
**Compatibility:** NaN equality semantics for floating-point operands, and
the existing lowering of basic comparisons, stay unchanged.
**Risks:** UDFs whose body is a compound comparison declared with a numeric
return type currently lower and return the boolean cast to a number. Once their
body is labeled boolean, the return-type check refuses them and they run as
interpreted Python, which returns NULL for a bool returned under a numeric type.
**Constraints:** Keep the NaN guard for genuinely numeric operands,
including negated floating-point operands that are still labeled numeric, so
NaN == NaN remains False in lowered equality.
**Exclusions:**
- Making unary plus and minus inherit their operand's category, and labeling
len() as integral, is left out. Both would also tighten the integral-only
repeat check and drop guards that can never fire, but neither is needed to fix
this analysis failure.
**Success:** A UDF such as `def f(s: str, t: str): return (len(s) > 0) ==
(len(t) > 0)` with a numeric return type no longer fails the query with an
isnan type mismatch; its body is labeled boolean and the call runs as
interpreted Python. The same body declared with a boolean return type lowers to
a plain boolean equality with no isnan. Lowered floating-point equality still
returns False for NaN == NaN and True for NaN != NaN.
**Verification:**
- **Regression:** With transpilation and ANSI mode enabled, an equality or
inequality between two comparisons over len() or ternary operands analyzes
successfully and returns the interpreted-Python results instead of failing the
query.
- **Behavior:** A boolean-returning equality between compound comparisons is
lowered, with no Python evaluation in the executed plan and no isnan, while
lowered floating-point equality over NaN rows still returns Python's results.
##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -1640,12 +1640,12 @@ def test_udf_transpile_evaluates_inputs_once(self):
# unresolved and ResolveRandomSeed then gives each spliced copy
its OWN seed. Both have
# to end up sharing one evaluation, or `x == x` compares two
independent draws.
#
- # Sharing leaves `col = col`, which SimplifyBinaryComparison folds
to true and column
- # pruning then drops the draw entirely -- so zero draws in the
plan is the proof that
- # both sides became the same column. Two independent draws would
leave both.
+ # The NaN guard for the untyped (numeric) parameter keeps one
`isnan` reference to the
+ # shared arg, so one draw remains in the plan after
SimplifyBinaryComparison folds
+ # `col == col` to true. Two independent draws would leave both.
for arg in (rand(), expr("rand()")):
eq_df = rows.select(eq_udf(arg).alias("v"))
- self.assertEqual(0, self._draw_count(eq_df))
+ self.assertEqual(1, self._draw_count(eq_df))
Review Comment:
**Nit (P3):** Before this change, `0` draws could only come from the lowered
path with both reads on one shared column. One draw is also what the optimized
plan shows when the JVM discards the option and runs the UDF as interpreted
Python, which also returns True on every row. Since the test only checks
`eq_udf.transpiled`, a regression that drops the option for these calls
(including the unresolved-seed `expr("rand()")` case only this test covers)
would pass. The sibling sharing tests pair their one-draw counts with
`_shares_an_argument`; adding that and `self.assertEqual(0,
self._eval_python_count(eq_df))` here restores the signal. Also, the guard that
keeps the draw comes from the float variant the JVM keeps for the DOUBLE
`rand()` argument, not from a "numeric" parameter as the new comment says.
**Verification:**
- **Regression:** The shared-draw check for `x == x` over rand() and
expr('rand()') fails when the transpiled option is discarded and the call runs
as interpreted Python.
##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -2636,17 +2636,120 @@ def bytes_ident(x: bytes):
def test_param_category_combos_caps_preserve_typed_pins(self):
# With more than three untyped params the cap collapses the untyped
ones
- # to numeric/string but keeps each typed param pinned (here a: str).
+ # to integer/float/string but keeps each typed param pinned (here a:
str).
import ast as _ast
from pyspark.sql.transpile import _param_category_combos
fn = _ast.parse("def f(a: str, b, c, d, e): return a").body[0]
combos = _param_category_combos(fn, ["a", "b", "c", "d", "e"])
- self.assertEqual(len(combos), 2)
+ # Three fill values: integer / float / string -> 3 combos.
+ self.assertEqual(len(combos), 3)
for combo in combos:
self.assertEqual(combo[0], "string")
+ def test_udf_transpile_integer_eq_no_nan_guard(self):
+ # `a == b` on BIGINT columns must NOT emit a NaN guard. Integers can
never
+ # be NaN, so emitting `isnan(cast(bigint as double))` is unnecessary
dead
+ # code. Verify via the logical plan: neither "isnan" nor an implicit
double
+ # cast should appear for the integer equality variant.
+ from pyspark.sql.types import StructField, StructType
+
+ def x_eq_y(x: int, y: int) -> bool:
+ return x == y
+
+ schema = StructType(
+ [
+ StructField("a", LongType(), nullable=True),
+ StructField("b", LongType(), nullable=True),
+ ]
+ )
+ with self.sql_conf(_TRANSPILE_ON):
+ pudf = UserDefinedFunction(x_eq_y, BooleanType())
+ self.assertTrue(pudf.transpiled, "annotated int == int should
transpile")
+ df = self.spark.createDataFrame([(1, 1), (1, 2), (None, None)],
schema=schema)
+ projected = df.select(pudf("a", "b").alias("r"))
+ # Verify no Python eval node (plan was rewritten to Catalyst).
+ self.assertEqual(0, self._eval_python_count(projected))
+ # Verify isnan is NOT in the logical plan.
+ logical_plan =
projected._jdf.queryExecution().analyzed().toString()
+ self.assertNotIn("isnan", logical_plan.lower(), "integer == must
not emit NaN guard")
+ # Verify correct results: (1==1, 1==2, None==None in Python ==
True).
+ results = [r[0] for r in projected.collect()]
+ self.assertEqual(results, [True, False, True])
+
+ def test_udf_transpile_float_eq_nan_semantics(self):
+ # `a == b` on DOUBLE columns must correctly implement Python's NaN
semantics:
+ # IEEE 754 says NaN != NaN (Python returns False), while Spark's
EqualTo
+ # returns True for NaN = NaN. The NaN guard fixes this for float
columns.
+ from pyspark.sql.types import DoubleType, StructField, StructType
+
+ def x_eq_y(x: float, y: float) -> bool:
+ return x == y
+
+ def x_neq_y(x: float, y: float) -> bool:
+ return x != y
+
+ schema = StructType(
+ [
+ StructField("a", DoubleType(), nullable=True),
+ StructField("b", DoubleType(), nullable=True),
+ ]
+ )
+ nan = float("nan")
+ with self.sql_conf(_TRANSPILE_ON):
+ pudf_eq = UserDefinedFunction(x_eq_y, BooleanType())
+ pudf_neq = UserDefinedFunction(x_neq_y, BooleanType())
+ self.assertTrue(pudf_eq.transpiled, "annotated float == float
should transpile")
+ self.assertTrue(pudf_neq.transpiled, "annotated float != float
should transpile")
+ # Verify isnan IS present in the logical plan (NaN guard was
emitted).
+ df = self.spark.createDataFrame([(1.0, 1.0)], schema=schema)
+ projected_eq = df.select(pudf_eq("a", "b").alias("r"))
+ logical_plan =
projected_eq._jdf.queryExecution().analyzed().toString()
+ self.assertIn("isnan", logical_plan.lower(), "float == must emit
NaN guard")
+ # Verify semantics: NaN == NaN -> False (Python), None == None ->
True.
+ rows = [
+ (nan, nan), # NaN == NaN -> False in Python
+ (nan, 1.0), # NaN == 1.0 -> False in Python
+ (1.0, 1.0), # 1.0 == 1.0 -> True
+ (1.0, 2.0), # 1.0 == 2.0 -> False
+ (None, None), # None == None -> True (Python semantics)
+ (None, 1.0), # None == 1.0 -> False
+ ]
+ df = self.spark.createDataFrame(rows, schema=schema)
+ eq_results = [r[0] for r in df.select(pudf_eq("a", "b")).collect()]
Review Comment:
**Nit (P3):** These result assertions run on a DataFrame whose plan is never
checked, and interpreted Python produces exactly these lists, so they also pass
if the call silently falls back. The `isnan` check above covers only `pudf_eq`
on a different one-row frame, and `pudf_neq` has no plan check; `_vals(...,
require_lowered=True)` in this file exists for exactly this case. Coverage is
also limited to bare, same-family annotated parameters. Nothing exercises an
untyped parameter reaching the float variant with NaN data, mixed equality such
as `x == 0` on DOUBLE, or derived operands such as `(a + 1) == (b + 1)`, whose
guard decision depends on `_wider_numeric`; if integer-plus-float widening
regressed to `"integer"`, those would return Spark's `NaN == NaN` result with
every test green. The header comment of
`test_udf_transpile_int_float_annotation_categories` ("the wider category is
used") is also not backed by its assertions, which only check helper outputs.
**Verification:**
- **Regression:** The NaN-equality tests fail when the guard is skipped for
float-derived operands, for example if integer-plus-float widening returned the
integer category, and when the call falls back to interpreted Python.
##########
python/pyspark/sql/transpile.py:
##########
@@ -496,15 +560,25 @@ def _category_uncached(self, params: List[str], node:
ast.AST) -> str:
case ast.BinOp(left=left, op=op, right=right):
lc = self._category(params, left)
rc = self._category(params, right)
- if isinstance(op, ast.Add) and lc == rc:
- return lc # str + str -> str, num + num -> num
+ if isinstance(op, ast.Add):
+ if lc == rc:
+ return lc # str+str, integer+integer, float+float
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
+ return _wider_numeric(lc, rc) # integer+float -> float
if isinstance(op, ast.Mult):
- if {lc, rc} == {"numeric", "numeric"}:
- return "numeric"
- if {lc, rc} == {"numeric", "string"}:
- return "string" # str * int / int * str -> repeat
- if isinstance(op, (ast.Sub, ast.Mod)) and lc == rc ==
"numeric":
- return "numeric"
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
+ return _wider_numeric(lc, rc)
+ # repeat(str, n): second operand must be integral (not
float).
+ # Python raises TypeError for `str * float`, so we allow
only
+ # "integer" or the legacy "numeric" catch-all (which the
JVM
Review Comment:
**Nit (P3):** No JVM step validates this. The JVM only receives
per-parameter categories from `_param_category_combos`, which never assigns
`"numeric"` after this change, and even a parameter-level `"numeric"` matches
FloatType/DoubleType in `optionMatchesTypes`. At this check `"numeric"` is
always an expression-level label: unary `+`/`-` (including negative literals),
`len()`, or `_wider_numeric` absorbing `"float"`. So `def f(s: str, n: float):
return s * -n` still lowers to `repeat(s, cast(-n as int))` and returns a
string where Python raises TypeError. That divergence predates this PR, but the
comment now tells maintainers it is closed. Please describe what is actually
enforced: only `"integer"` comes from a validated parameter type, and an
expression-level `"numeric"` multiplier can still be a float.
**Verification:**
- **Inspection:** The comment on the string-repetition category check
matches the categories the check accepts and claims no JVM validation for
expression-level categories.
##########
python/pyspark/sql/transpile.py:
##########
@@ -754,20 +833,20 @@ def _convert_chunk(self, params: List[str], body: ast.AST
| None) -> Column:
case ast.Add():
if lc == rc == "string":
return concat(left_col, right_col)
- if lc == rc == "numeric":
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
return left_col.__add__(right_col)
case ast.Sub():
- if lc == rc == "numeric":
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
return left_col.__sub__(right_col)
case ast.Mult():
- if lc == "numeric" and rc == "numeric":
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
return left_col.__mul__(right_col)
- if lc == "string" and rc == "numeric":
+ if lc == "string" and rc in ("integer", "numeric"):
Review Comment:
**Nit (P3):** This check (and the matching one at lines 575-578) newly
refuses a float multiplier. On branch-4.x before this change, a
string-returning UDF such as `a * 2.0` on a STRING column, or `a * b` over
(string, double), lowered to `repeat(s, cast(n as int))` and silently returned
a truncated repetition where Python raises TypeError. That is a real
correctness fix, but nothing tests it:
`test_udf_transpile_string_operands_fall_back` has no `str * float` case, so
reverting to `_is_numeric_cat(rc)` would leave every test green. When adding
the case, declare a string return type: the existing `_raises` helper declares
`LongType`, under which the repeat variant already fails the return-type check,
so a case built on it would pass with or without this fix.
**Verification:**
- **Regression:** A string-times-float UDF declared with a string return
type, with either a float literal or a DOUBLE-bound multiplier, runs as
interpreted Python and raises TypeError, and the test fails if the multiplier
check is reverted so the call lowers to repeat.
##########
python/pyspark/sql/transpile.py:
##########
@@ -372,31 +405,56 @@ def _lower_eq(
where Python's ``==`` is simply False. Refuse those so the UDF falls
back to interpreted Python. A ``None`` literal operand stays allowed
(the four-branch NULL handling above reproduces Python exactly).
+ Numeric sub-categories (``"integer"`` vs ``"float"``) are treated as
+ compatible; Spark widens to double in the comparison.
+
+ A NaN guard is added unless both operands are provably ``"integer"``:
+ ``NaN == NaN`` is ``False`` in Python (IEEE 754), but Spark's
+ ``EqualTo`` returns ``True``. ``"numeric"`` is an unknown sub-type
+ that may resolve to a float column, so it keeps the guard.
- One value-level difference remains (needs runtime values, so it is
- documented, not guarded): Spark treats ``NaN = NaN`` as true, while
- Python's ``nan == nan`` is False.
+ One value-level difference remains for ordering comparisons (tracked
+ separately): Spark orders NaN as greater than every value, whereas
+ Python's NaN comparisons are all False.
"""
lc = self._safe_category(params, left_node)
rc = self._safe_category(params, right_node)
if lc is not None and rc is not None and lc != rc:
- raise UnsupportedOperationException(
- f"`==`/`!=` operands have incompatible categories ({lc} vs
{rc}); "
- "Python compares across types as unequal while Spark would
coerce "
- "or fail analysis, so the transpiler falls back to interpreted
Python"
- )
+ # Numeric sub-categories are mutually compatible for equality.
+ if not (_is_numeric_cat(lc) and _is_numeric_cat(rc)):
+ raise UnsupportedOperationException(
+ f"`==`/`!=` operands have incompatible categories ({lc} vs
{rc}); "
+ "Python compares across types as unequal while Spark would
coerce "
+ "or fail analysis, so the transpiler falls back to
interpreted Python"
+ )
left_col = self._convert_chunk(params, left_node)
right_col = self._convert_chunk(params, right_node)
left_null = left_col.isNull()
right_null = right_col.isNull()
+ # NaN guard: Python's `NaN == NaN` is False (IEEE 754 reflexivity
fails),
+ # but Spark's EqualTo returns True. Emit only for numeric operands, and
+ # skip only when both are provably integral ("integer"); "numeric" is
an
+ # unknown sub-type that may resolve to a float column, so it keeps the
guard.
+ if (
+ lc is not None
+ and rc is not None
+ and _is_numeric_cat(lc)
+ and _is_numeric_cat(rc)
+ and not (lc == "integer" and rc == "integer")
+ ):
+ nan_result = lit(not equal)
+ nan_cmp = isnan(left_col) | isnan(right_col)
Review Comment:
**Non-blocking (P2):** Spark's `EqualTo` diverges from Python only when both
operands are NaN; with one NaN operand both already return false for `==`. This
guard fires whenever either side may be NaN, so it is dead for `x == 0.5` or `x
== 0` on DOUBLE, or `x == 0.5` on BIGINT. Its CASE form also survives
optimization: `isnan(0.5)` folds to false, but `CASE WHEN isnan(x) THEN false
ELSE x = 0.5 END` remains, and `SimplifyConditionals` only rewrites the
true/false CASE pairs.
Compared with branch-4.x before this change, which emitted no guard:
- A filter `udf(is_half, "boolean")(t.x)` with `def is_half(x: float) ->
bool: return x == 0.5` over a JDBC catalog table (DSv2; JDBC columns are always
nullable) used to reduce to `CASE WHEN isnull(x) THEN false ELSE x = 0.5 END`,
which `V2ExpressionBuilder` translates and JDBC can push. With the guard the
predicate contains `isnan`, which has no V2 translation, so it is not pushed
and the source returns every row for Spark to filter.
- A join condition `udf(same, "boolean")(l.a, r.b)` with `def same(a: float,
b: float) -> bool: return a == b` over non-nullable DOUBLE keys (for example
computed with `coalesce`) used to fold to `a = b`, which `ExtractEquiJoinKeys`
takes as an equi-key. Now no top-level `EqualTo` remains, so the join becomes a
broadcast nested-loop or Cartesian join.
Guarding only the both-NaN case inside the existing NULL handling keeps the
semantics: `(l = r) AND NOT (isnan(l) AND isnan(r))` for `==` and `(l != r) OR
(isnan(l) AND isnan(r))` for `!=`, emitted only when neither operand is
`"integer"`. Against a non-NaN literal this folds back to the plain comparison,
and between columns it keeps the equality conjunct the planner needs.
**Verification:**
- **Behavior:** Lowered equality and inequality over floating-point rows
containing NaN, NULL and ordinary values return Python's results.
- **Regression:** The optimized plan of a lowered floating-point
column-versus-literal equality contains no isnan, and a lowered equality over
non-nullable floating-point keys used as a join condition yields an equi-join
key.
--
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]