From af822595bb5cd4961d9392c47ec31e1cfc93d497 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 13 Jul 2026 16:26:30 +0300 Subject: [PATCH] Add int32 fast lane to the raw-double remainder sites TryCompoundUnboxed, TryAssignNumeric and ModuloEqualityLane computed JS % as double % double - a native fmod call - even for the dominant integral loop-counter shapes, while the shared Remainder helper already had an integer fast path for boxed Integer operands. Route all three through a shared RemainderUnboxed helper: int32 round-trip gate (NaN/Infinity/fractional/out-of-range/zero-divisor operands defer to fmod), remainder in long math (int.MinValue % -1 would raise a hardware overflow in 32-bit division), and a zero remainder takes the dividend's sign bit so negative and -0 dividends produce the spec -0. Co-Authored-By: Claude Fable 5 --- Jint.Tests/Runtime/NumberTests.cs | 74 +++++++++++++++++++ .../Expressions/JintAssignmentExpression.cs | 10 ++- .../Expressions/JintBinaryExpression.cs | 12 +-- .../Interpreter/Expressions/JintExpression.cs | 28 +++++++ 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/Jint.Tests/Runtime/NumberTests.cs b/Jint.Tests/Runtime/NumberTests.cs index f677ad63b..66b37f46e 100644 --- a/Jint.Tests/Runtime/NumberTests.cs +++ b/Jint.Tests/Runtime/NumberTests.cs @@ -266,6 +266,80 @@ public void IntegerMultiplicationPreservesNegativeZero() Assert.Equal("true,true,true,true,true,true,true,true,true,true,true,true,true,true", result); } + [Fact] + public void IntegerRemainderLanesPreserveSignAndSpecialCases() + { + var engine = new Engine(); + // Number::remainder: the result takes the dividend's sign, so a zero remainder from a + // negative (or -0) dividend is -0. The unboxed raw-double lanes (compound `x %= y`, + // statement `lhs = a % b`, fused `x % c === c`) compute integral operands with int32 + // math and must reproduce this exactly, deferring NaN/fractional/zero-divisor/out-of-range + // operands to fmod. The loop over slot-stored locals keeps the lanes engaged and their + // slot caches hot; every check pushes true. + var result = engine.Evaluate(""" + (function () { + var r = []; + for (var i = 0; i < 3; i++) { + // compound `x %= y` shape + var a = -7, b = 2; + a %= b; + r.push(a === -1); + var c = 7, d = -2; + c %= d; + r.push(c === 1); + var negFour = -4, two = 2; + negFour %= two; + r.push(Object.is(negFour, -0)); + var four = 4; + four %= two; + r.push(Object.is(four, 0)); + var n = 5; + n %= 0; + r.push(Number.isNaN(n)); + var f = 5.5; + f %= two; + r.push(f === 1.5); + var min = -2147483648, negOne = -1; + min %= negOne; + r.push(Object.is(min, -0)); + var negZero = -0; + negZero %= two; + r.push(Object.is(negZero, -0)); + + // `lhs = a % b` statement shape + var res = 0; + var negFourB = -4, fourB = 4, sevenB = 7, negTwoB = -2, minB = -2147483648, fracB = 5.5; + res = negFourB % 2; + r.push(Object.is(res, -0)); + res = fourB % 2; + r.push(Object.is(res, 0)); + res = sevenB % negTwoB; + r.push(res === 1); + res = minB % negOne; + r.push(Object.is(res, -0)); + res = fracB % 2; + r.push(res === 1.5); + res = sevenB % 0; + r.push(Number.isNaN(res)); + + // fused `x % constant === constant` equality shape (a -0 remainder + // compares equal to 0 under IEEE ===) + var e1 = -4, e2 = 4, e3 = -7, e4 = 7, e5 = 5, e6 = 5.5, e7 = -2147483648; + r.push(e1 % 2 === 0); + r.push(e2 % 2 === 0); + r.push(e3 % 2 === -1); + r.push(e4 % -2 === 1); + r.push(e5 % 0 !== 0); + r.push(e6 % 2 === 1.5); + r.push(e7 % -1 === 0); + } + return r.join(','); + })() + """).AsString(); + + Assert.Equal(string.Join(",", Enumerable.Repeat("true", 63)), result); + } + // The following tests guard the primitive-receiver method resolution that skips allocating a // Number/Boolean/BigInt wrapper on `primitive.method()`. The wrapper was only ever a lookup // vehicle (the this-value passed to the callee is the primitive, boxed at call time for sloppy diff --git a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs index d7b6c07d9..446bf4e5e 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs @@ -402,8 +402,9 @@ private bool TryCompoundUnboxed(EvaluationContext context) result = left / right; break; case Operator.RemainderAssignment: - // IEEE 754 remainder (fmod) matches the ECMAScript algorithm for all special cases - result = left % right; + // int32 lane avoids native fmod for integral operands; the fallback IEEE 754 + // remainder (fmod) matches the ECMAScript algorithm for all special cases + result = RemainderUnboxed(left, right); break; case Operator.BitwiseAndAssignment: result = TypeConverter.ToInt32(left) & TypeConverter.ToInt32(right); @@ -887,8 +888,9 @@ private bool TryAssignNumeric(EvaluationContext context) result = left / right; break; case Operator.Remainder: - // IEEE 754 remainder (fmod) matches the ECMAScript algorithm for all special cases - result = left % right; + // int32 lane avoids native fmod for integral operands; the fallback IEEE 754 + // remainder (fmod) matches the ECMAScript algorithm for all special cases + result = RemainderUnboxed(left, right); break; default: return false; diff --git a/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs index c3dc0797f..7a8490982 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintBinaryExpression.cs @@ -649,11 +649,11 @@ private static bool TryReadIndex(JintIdentifierExpression? indexIdentifier, ref /// /// Fused lane for the `identifier % numericConstant == numericConstant` test — the /// stopwatch.js if-chain shape. Reads the identifier through the same slot/global arms as - /// and computes the whole test on raw doubles. - /// C# double % implements Number::remainder exactly, and the ways integer and double - /// remainders can disagree (−0 vs +0 results) are erased by the equality consumer - /// (IEEE == treats ±0 as equal), so the fused result is spec-exact for any proven-Number - /// operand: NaN dividends/divisors (and % 0) compare false, v % ±Infinity == v. + /// and computes the whole test on raw doubles + /// via , which implements Number::remainder + /// exactly (int32 lane for integral operands, IEEE 754 fmod otherwise), so the fused + /// result is spec-exact for any proven-Number operand: NaN dividends/divisors (and % 0) + /// compare false, v % ±Infinity == v, and ±0 results compare equal under IEEE ==. /// private protected struct ModuloEqualityLane { @@ -700,7 +700,7 @@ public bool TryEvaluate(EvaluationContext context, out bool equal) } } - equal = value % _divisor == _expected; + equal = RemainderUnboxed(value, _divisor) == _expected; return true; } } diff --git a/Jint/Runtime/Interpreter/Expressions/JintExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintExpression.cs index a99f4d19d..86ec549d8 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintExpression.cs @@ -252,6 +252,34 @@ protected static JsValue Remainder(EvaluationContext context, JsValue left, JsVa return result; } + /// + /// Number::remainder over raw doubles with an int32 fast lane — the raw-double lanes + /// otherwise pay a native fmod call even for the dominant integral loop-counter shapes. + /// Anything failing the round-trip gate (NaN, ±Infinity, fractional values, operands + /// beyond int32 range, a zero divisor) falls back to IEEE 754 %, which matches the + /// ECMAScript algorithm for all special cases. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static double RemainderUnboxed(double left, double right) + { + // int32-representable check: NaN/±Infinity/fractional values fail the round-trip + // equality and defer to fmod, as does a zero divisor (fmod gives the spec NaN); + // a -0 dividend round-trips to 0 and is re-signed below + var leftInteger = (int) left; + var rightInteger = (int) right; + if (leftInteger == left && rightInteger == right && rightInteger != 0) + { + // long math: int.MinValue % -1 raises a hardware overflow in 32-bit division + var modulo = (long) leftInteger % rightInteger; + // Number::remainder gives the result the dividend's sign, so a zero remainder + // from a negative (or -0) dividend is -0 — integer math cannot represent it, + // test the sign bit of the original double (left is proven non-NaN by the gate) + return modulo == 0 && BitConverter.DoubleToInt64Bits(left) < 0 ? -0.0 : modulo; + } + + return left % right; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] protected static JsValue Divide(EvaluationContext context, JsValue left, JsValue right) {