Skip to content

[SPARK-58582][PS][FOLLOWUP] Keep integral operands in integer space for NumPy fmod - #58319

Open
Spenserrrr wants to merge 2 commits into
apache:masterfrom
Spenserrrr:numpy-fmod-int-precision
Open

[SPARK-58582][PS][FOLLOWUP] Keep integral operands in integer space for NumPy fmod#58319
Spenserrrr wants to merge 2 commits into
apache:masterfrom
Spenserrrr:numpy-fmod-int-precision

Conversation

@Spenserrrr

@Spenserrrr Spenserrrr commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

_fmod_func cast both operands to double before taking the remainder, including on the branch reached only when neither operand is floating. Integral operands now take the remainder as longs, and only the result is cast back to double.

Why are the changes needed?

A double carries 53 bits of mantissa, so an integral operand above 2**53 was rounded before the remainder was taken: np.fmod(9007199254740993, 2) gave 0.0 where pandas gives 1. The same cast also returned -0.0 for -64 % 2, a sign of zero an integral remainder cannot have. The pandas_udf this mapping replaced called NumPy directly and was exact.

Does this PR introduce any user-facing change?

No.

How was this patch tested?

New rows in test_np_fmod for integral operands above 2**53, compared exactly since a relative tolerance accepts an off-by-one at those magnitudes, plus an np.signbit assertion for the sign of zero. Both fail without the change.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5)

…or NumPy fmod

The native `fmod` mapping cast both operands to double before taking the
remainder, including on the branch reached only when neither operand is
floating. A double carries 53 bits of mantissa, so an integral operand above
2**53 was rounded before the remainder was ever taken: `9007199254740993 % 2`
returned 0 where NumPy and pandas return 1. Every epoch-nanosecond value is
past that boundary.

The same cast also gave an integral remainder a sign of zero it cannot have,
since an IEEE remainder carries the dividend's sign: `-64 % 2` returned -0.0
where NumPy returns 0.

Integral operands now take the remainder as longs, and only the result is cast
back to double. The floating branch is unchanged, and it is faithful for a
mixed pair because NumPy promotes to float64 there and loses the same bits.

A remainder above 2**53 is still rounded by the double return type, which needs
a divisor above 2**53; the result is exact for any divisor up to it.

The added test rows compare exactly rather than approximately, since a relative
tolerance accepts an off-by-one at those magnitudes, and an explicit signbit
assertion covers the sign of zero, which `almost=True` cannot see.
@Spenserrrr
Spenserrrr marked this pull request as ready for review August 26, 2026 19:01
@Spenserrrr

Spenserrrr commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Hi @zhengruifeng! This is a PR to fix the fmod function. The original _fmod_func cast both operands to double before try_mod, so an integral operand above 2**53 lost its low bits. Right now, the code takes the remainder as longs and only the result is cast back to double. Could you take a look when you have time?

While checking the rest of the table, I found the same double cast affects trunc, fmax, and fmin. I'll follow up on those separately unless you'd rather keep them out of scope. Thanks!
cc @Yicong-Huang

Comment thread python/pyspark/pandas/numpy_compat.py Outdated
.when(c2_double == 0, F.lit(float("nan")))
.otherwise(F.try_mod(c1_double, c2_double)),
).otherwise(
# Non-floating operands, where NumPy normalizes a zero divisor to 0 instead of a NaN.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will numpy raise a error for types like string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NumPy always raises on a string column. Ours raises only conditionally: ["7", "8"] computes and returns [1.0, 2.0], while ["7", "abc"] fails at runtime with CAST_INVALID_INPUT, because we cast columns to double at the start of the helper function. This PR does not change this part.

Note that the current mapping table doesn't validates dtypes so each entry just accepts whatever Spark can cast. If we want to fix this, we may need to add dtype check in the mapping table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also flattened the dispatch here to match your suggestion on #58306, with one branch per operand type.

One thing I noticed while doing it: NumPy raises on Decimal objects too, so the decimal branch has no NumPy behavior to match. I kept your original answer there, which is 0 for a zero divisor and the remainder in double. I think fixing it properly needs the same dtype check as the string case.

The mapping dispatched on whether either operand was floating and then repeated
the null and nan rungs inside both branches. It now dispatches on the operand
type directly, with one branch each for integral, floating and decimal operands,
so those rungs are written once and each branch carries its own zero divisor
answer.

Behavior is unchanged. The old and new expressions were compared row by row with
both results cast to string, so -0.0, NaN and Infinity compare exactly: 851 rows
over twelve dtype combinations, covering double specials, random doubles,
integral edges above 2**53, narrow integers, float32, mixed integral and
floating pairs, and decimals. String, boolean, timestamp and date columns were
checked separately and are unchanged too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants