This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new ff17f551c384 [SPARK-59696][SQL] Skip the redundant second modulo in 
integral MathUtils.pmod for positive divisors
ff17f551c384 is described below

commit ff17f551c3847752bbd8e427b863aeaf4a1ec8ce
Author: david-mollitor-db <[email protected]>
AuthorDate: Wed Oct 7 08:52:12 2026 -0700

    [SPARK-59696][SQL] Skip the redundant second modulo in integral 
MathUtils.pmod for positive divisors
    
    ### What changes were proposed in this pull request?
    
    For integral `MathUtils.pmod(a, n)` with `n > 0`, skip the redundant second 
modulo in the released `r = a % n; if (r < 0) (r + n) % n else r`. For `n > 0` 
the result is `Math.floorMod(a, n)`: a negative remainder only needs `n` added. 
This PR computes it with a branchless sign fix-up:
    
    ```scala
    def pmod(a: Int, n: Int): Int = {
      val r = a % n
      if (n > 0) r + (n & (r >> 31)) else if (r >= 0) r else (r + n) % n
    }
    // Long: the same with `r >> 63`.
    ```
    
    `n < 0` keeps the original `(r + n) % n`. `Byte`/`Short` delegate to `Int`. 
`Float`/`Double` are unchanged.
    
    ### Why are the changes needed?
    
    `pmod` with a positive divisor is the common case (e.g. `HashPartitioning` 
passes `numPartitions` for every row), and the second `% n` is a no-op there.
    
    The fix-up is written out because `Math.floorMod` adjusts with a 
data-dependent conditional, which C2 often keeps as a branch that mispredicts 
on random-sign dividends. JMH 1.37, x86_64, 16,384 `pmod(int, int)` calls per 
op over random-sign dividends, 3 forks x 10 iterations; us/op:
    
    | divisor | JDK | released | `Math.floorMod` | this PR |
    |---|---|---|---|---|
    | JIT constant `200` | 17 | 20.5 | 31.9 (bimodal) | 14.9 |
    | | 25 | 31.9 | 43.4 | 4.5 (*) |
    | per-row `200` | 17 | 65.9 | 81.7 | 26.1 |
    | | 25 | 70.7 | 40.6 | 26.2 |
    | loop-invariant `200` | 17 | 89.0 | 42.2 | 26.1 |
    | | 25 | 65.7 | 25.7 | 25.4 |
    
    (*) Likely the benchmark loop vectorizing, which does not apply to per-row 
evaluation such as `HashPartitioning`. JDK 21 and ARM were not measured.
    
    Harness (JMH and exhaustive equivalence check): 
https://gist.github.com/david-mollitor-db/1cc2fe01b478062efda9ec191efd0f51
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. Results are identical for every input.
    
    ### How was this patch tested?
    
    New cases in `ArithmeticExpressionSuite` "pmod":
    - `pmod(-7, 3)` via `testNumericDataTypes`, which exercises the integral `n 
> 0` fast path (`Byte`/`Short` via `Int`, and `Long`);
    - `pmod(-6, 3) == 0` (`Int`, `Long`): a zero remainder with a negative 
dividend;
    - a `Long` case whose remainder is below `-2^62`, so a wrong shift count or 
narrowing to `Int` changes the result;
    - `pmod(-1e-20, 1.0) == 0.0` (and the `Float` analogue), which needs the 
float/double `% n`;
    - negative-divisor cases, and two assertions characterizing the 
pre-existing `Int`/`Long` `n < 0` wrap-around (SPARK-59934).
    
    The integral bodies were also checked for exact equality with the released 
ones: all `byte` and `short` pairs, all 2^32 `int` dividends for 26 divisors 
(including `Int.MinValue` and `-1073741825`), and `long` edge values plus 4e8 
random pairs.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.8
    
    This pull request and its description were written by Isaac.
    
    Closes #58906 from david-mollitor-db/pmod-drop-redundant-modulo.
    
    Authored-by: david-mollitor-db <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../apache/spark/sql/catalyst/util/MathUtils.scala | 29 ++++++++++++----------
 .../sql/catalyst/expressions/arithmetic.scala      |  8 +++---
 .../expressions/ArithmeticExpressionSuite.scala    | 22 +++++++++++++++-
 3 files changed, 41 insertions(+), 18 deletions(-)

diff --git 
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/MathUtils.scala 
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/MathUtils.scala
index e318a60ce99a..b829bcd57b40 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/MathUtils.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/MathUtils.scala
@@ -89,29 +89,32 @@ object MathUtils {
 
   def floorMod(a: Long, b: Long): Long = withOverflow(Math.floorMod(a, b))
 
-  // Positive modulo (`pmod`): the remainder `a % n` adjusted to share the 
sign of `n`.
-  // Unlike `floorMod`, this matches the `pmod` SQL function / 
`HashPartitioning` semantics.
-  // Shared by `Pmod`'s eval and codegen paths so the two never diverge.
+  // Positive modulo (`pmod`): for `n > 0` the result lies in `[0, n)` (for 
the float/double
+  // overloads, when both inputs are finite); for integral `n < 0` it takes 
the sign of the
+  // dividend `a` (unlike `floorMod`, which takes the sign of `n`). Matches 
the `pmod` SQL
+  // function and `HashPartitioning`, and is shared by `Pmod`'s eval and 
codegen so the two never
+  // diverge.
+  //
+  // Integral `n > 0` computes `Math.floorMod(a, n)` with a branchless sign 
fix-up instead of
+  // calling it, because `Math.floorMod` adjusts with a data-dependent 
conditional (SPARK-59696).
+  // The `n < 0` path can silently wrap for `Int`/`Long` when `r + n` 
overflows (large-magnitude
+  // negative `n`), e.g. `pmod(-1, Int.MinValue)` returns `Int.MaxValue`; 
tracked by SPARK-59934.
+  // `Byte`/`Short` delegate to `Int` (too narrow for `r + n` to overflow); 
the float/double
+  // overloads keep `% n` because `r + n` can round up to exactly `n`.
 
   def pmod(a: Int, n: Int): Int = {
     val r = a % n
-    if (r < 0) (r + n) % n else r
+    if (n > 0) r + (n & (r >> 31)) else if (r >= 0) r else (r + n) % n
   }
 
   def pmod(a: Long, n: Long): Long = {
     val r = a % n
-    if (r < 0) (r + n) % n else r
+    if (n > 0) r + (n & (r >> 63)) else if (r >= 0) r else (r + n) % n
   }
 
-  def pmod(a: Byte, n: Byte): Byte = {
-    val r = a % n
-    if (r < 0) ((r + n) % n).toByte else r.toByte
-  }
+  def pmod(a: Byte, n: Byte): Byte = pmod(a.toInt, n.toInt).toByte
 
-  def pmod(a: Short, n: Short): Short = {
-    val r = a % n
-    if (r < 0) ((r + n) % n).toShort else r.toShort
-  }
+  def pmod(a: Short, n: Short): Short = pmod(a.toInt, n.toInt).toShort
 
   def pmod(a: Float, n: Float): Float = {
     val r = a % n
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/arithmetic.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/arithmetic.scala
index d0d7507e3250..233879fe425c 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/arithmetic.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/arithmetic.scala
@@ -1239,10 +1239,10 @@ case class Pmod(
            |${ev.isNull} = ${ev.value} == null;
            |""".stripMargin
 
-      // The positive-modulo arithmetic is the same fixed algorithm for every 
primitive numeric
-      // type, so delegate to the shared MathUtils.pmod helper (also used by 
the eval path) instead
-      // of emitting the remainder/adjust block inline. byte/short are widened 
to int by `%`, and
-      // the matching MathUtils.pmod overload narrows the result back.
+      // The positive-modulo arithmetic has the same semantics for every 
primitive numeric type,
+      // so delegate to the shared MathUtils.pmod helper (also used by the 
eval path) instead of
+      // emitting the remainder/adjust block inline. The byte/short overloads 
compute in int and
+      // narrow the result back.
       case _ =>
         s"${ev.value} = $mathUtils.pmod(${eval1.value}, ${eval2.value});"
     }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/ArithmeticExpressionSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/ArithmeticExpressionSuite.scala
index f8cdf825bc4c..13847fee8f44 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/ArithmeticExpressionSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/ArithmeticExpressionSuite.scala
@@ -565,6 +565,8 @@ class ArithmeticExpressionSuite extends SparkFunSuite with 
ExpressionEvalHelper
       val left = Literal(convert(7))
       val right = Literal(convert(3))
       checkEvaluation(Pmod(left, right), convert(1))
+      // Exercises the integral `n > 0` fast path (`Byte`/`Short` via `Int`, 
and `Long`).
+      checkEvaluation(Pmod(Literal(convert(-7)), Literal(convert(3))), 
convert(2))
       checkEvaluation(Pmod(Literal.create(null, left.dataType), right), null)
       checkEvaluation(Pmod(left, Literal.create(null, right.dataType)), null)
       withSQLConf(SQLConf.ANSI_ENABLED.key -> "false") {
@@ -574,14 +576,32 @@ class ArithmeticExpressionSuite extends SparkFunSuite 
with ExpressionEvalHelper
         checkExceptionInExpression[ArithmeticException](
           Pmod(left, Literal(convert(0))), "Remainder by zero")
       }
+      // Negative divisor (n < 0): `pmod` is only guaranteed non-negative for 
a positive divisor.
+      // These cases have negative dividends, so the expected values are <= 0 
(released behavior).
+      // They guard the r < 0, n < 0 path where `r + n` still needs `% n` -- 
dropping it goes out
+      // of range (pmod(-3, -5) would be -8).
+      checkEvaluation(Pmod(Literal(convert(-3)), Literal(convert(-5))), 
convert(-3))
+      checkEvaluation(Pmod(Literal(convert(-7)), Literal(convert(-3))), 
convert(-1))
     }
-    checkEvaluation(Pmod(Literal(-7), Literal(3)), 2)
     checkEvaluation(Pmod(Literal(7.2D), Literal(4.1D)), 3.1000000000000005)
+    checkEvaluation(Pmod(Literal(-1e-20D), Literal(1.0D)), 0.0D)
+    checkEvaluation(Pmod(Literal(-1e-10f), Literal(1.0f)), 0.0f)
     checkEvaluation(Pmod(Literal(Decimal(0.7)), Literal(Decimal(0.2))), 
Decimal(0.1))
     checkEvaluation(Pmod(Literal(2L), Literal(Long.MaxValue)), 2L)
+    checkEvaluation(Pmod(Literal(-6), Literal(3)), 0)
+    checkEvaluation(Pmod(Literal(-6L), Literal(3L)), 0L)
+    // The remainder is below -2^62, so a wrong shift count or narrowing to 
Int changes it.
+    checkEvaluation(
+      Pmod(Literal(Long.MinValue / 2 - 1), Literal(Long.MaxValue)), 
4611686018427387902L)
     checkEvaluation(Pmod(positiveShort, negativeShort), positiveShort.toShort)
     checkEvaluation(Pmod(positiveInt, negativeInt), positiveInt)
     checkEvaluation(Pmod(positiveLong, negativeLong), positiveLong)
+    // Pre-existing Int/Long wrap-around, pinned so the released results stay 
exact: the retained
+    // `(r + n) % n` overflows for n < -2^30 / n < -2^62. Without overflow `(r 
+ n) % n == r`, so
+    // these are the only inputs that tell the two apart. These assertions 
characterize the current
+    // released behavior; fixing the wrap-around is tracked by SPARK-59934.
+    checkEvaluation(Pmod(Literal(-1), Literal(Int.MinValue)), Int.MaxValue)
+    checkEvaluation(Pmod(Literal(-1L), Literal(Long.MinValue)), Long.MaxValue)
 
     Seq("true", "false").foreach { failOnError =>
       withSQLConf(SQLConf.ANSI_ENABLED.key -> failOnError) {


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

Reply via email to