diff --git a/Compilation/ForLoopAnalyzer.cs b/Compilation/ForLoopAnalyzer.cs
index 7231bd08..a9d810ff 100644
--- a/Compilation/ForLoopAnalyzer.cs
+++ b/Compilation/ForLoopAnalyzer.cs
@@ -29,6 +29,17 @@ public static class ForLoopAnalyzer
public static readonly bool IntegerCounterEnabled =
Environment.GetEnvironmentVariable("SHARPTS_INT_LOOP_COUNTER") != "0";
+ ///
+ /// #928: native int64 modulo for counter % integerLiteral. The integer loop
+ /// counter is already an Int64 slot; emitting i % 7 as an int64 rem (then
+ /// conv.r8) instead of an FP fmod on two doubles is bit-identical to JS truncated
+ /// remainder for every |i| ≤ 2^53 — the same gate the counter representation already
+ /// accepts — and the fmod is the dominant per-iteration cost in write kernels (#928
+ /// measurement). On by default when the counter is on; kill with SHARPTS_INT_MOD=0.
+ ///
+ public static readonly bool IntegerModuloEnabled =
+ Environment.GetEnvironmentVariable("SHARPTS_INT_MOD") != "0";
+
///
/// Identifies a for-loop counter eligible for the native Int64 representation, or null.
/// Stricter than : requires an INTEGER-literal initializer, a pure
diff --git a/Compilation/ILEmitter.Operators.cs b/Compilation/ILEmitter.Operators.cs
index f3b33c97..c4aec78a 100644
--- a/Compilation/ILEmitter.Operators.cs
+++ b/Compilation/ILEmitter.Operators.cs
@@ -83,6 +83,14 @@ protected override void EmitBinary(Expr.Binary b)
break;
case Arithmetic arith:
+ // #928: `counter % intLiteral` → native int64 rem instead of FP fmod (the dominant
+ // per-iteration write-kernel cost). Sound within the int-counter gate (truncated
+ // remainder matches JS for |i| ≤ 2^53). Falls through to the double path otherwise.
+ if (arith.Opcode == OpCodes.Rem && TryEmitIntegerCounterModulo(b))
+ {
+ SetStackType(StackType.Double);
+ break;
+ }
// Numeric arithmetic with direct IL opcodes
EmitExpressionAsDouble(b.Left);
EmitExpressionAsDouble(b.Right);
@@ -950,6 +958,60 @@ private static bool TryGetIntLiteralValue(Expr e, out long value)
return true;
}
+ ///
+ /// #928: emits counter % integerLiteral as a native int64 rem followed by
+ /// conv.r8, replacing the per-iteration FP fmod (the dominant write-kernel cost).
+ /// The left operand must be an integer-counter expression (the counter, or counter ± int literal)
+ /// and the right a non-zero integer literal. C# long % and JS % are both truncated
+ /// (result takes the dividend's sign), so this is bit-identical to the double computation for every
+ /// |dividend| ≤ 2^53 — the same range the int-counter representation already accepts. Divisor
+ /// 0 is left to the double path (fmod(x,0)=NaN), so no DivideByZeroException is risked.
+ /// Returns false (emitting nothing) when the shape does not qualify.
+ ///
+ private bool TryEmitIntegerCounterModulo(Expr.Binary b)
+ {
+ if (!ForLoopAnalyzer.IntegerModuloEnabled) return false;
+ if (!TryGetIntLiteralValue(b.Right, out long divisor) || divisor == 0) return false;
+ if (!TryEmitIntegerCounterValueI8(b.Left)) return false;
+ IL.Emit(OpCodes.Ldc_I8, divisor);
+ IL.Emit(OpCodes.Rem);
+ IL.Emit(OpCodes.Conv_R8);
+ return true;
+ }
+
+ ///
+ /// Emits an integer-counter expression as a native Int64 (no conv.r8): the counter
+ /// i, or i ± intLiteral / intLiteral + i. Companion to
+ /// but leaves the value as Int64 on the stack. Returns
+ /// false (emitting nothing) when the shape is not a recognized integer-counter expression.
+ ///
+ private bool TryEmitIntegerCounterValueI8(Expr e)
+ {
+ switch (e)
+ {
+ case Expr.Variable v when IsIntegerCounterLocal(v.Name.Lexeme):
+ IL.Emit(OpCodes.Ldloc, _ctx.Locals.GetLocal(v.Name.Lexeme)!);
+ return true;
+
+ case Expr.Binary { Operator.Type: TokenType.PLUS or TokenType.MINUS } bb
+ when bb.Left is Expr.Variable lv && IsIntegerCounterLocal(lv.Name.Lexeme)
+ && TryGetIntLiteralValue(bb.Right, out long k):
+ IL.Emit(OpCodes.Ldloc, _ctx.Locals.GetLocal(lv.Name.Lexeme)!);
+ IL.Emit(OpCodes.Ldc_I8, k);
+ IL.Emit(bb.Operator.Type == TokenType.PLUS ? OpCodes.Add : OpCodes.Sub);
+ return true;
+
+ case Expr.Binary { Operator.Type: TokenType.PLUS } b2
+ when b2.Right is Expr.Variable rv && IsIntegerCounterLocal(rv.Name.Lexeme)
+ && TryGetIntLiteralValue(b2.Left, out long k2):
+ IL.Emit(OpCodes.Ldc_I8, k2);
+ IL.Emit(OpCodes.Ldloc, _ctx.Locals.GetLocal(rv.Name.Lexeme)!);
+ IL.Emit(OpCodes.Add);
+ return true;
+ }
+ return false;
+ }
+
///
/// Native int64 increment for an integer-counter local: i++/i-- (postfix) or
/// ++i/--i (prefix). Mutates the slot in place and leaves the postfix-old /
diff --git a/SharpTS.Tests/SharedTests/ModuloParityTests.cs b/SharpTS.Tests/SharedTests/ModuloParityTests.cs
new file mode 100644
index 00000000..a9d88a81
--- /dev/null
+++ b/SharpTS.Tests/SharedTests/ModuloParityTests.cs
@@ -0,0 +1,130 @@
+using SharpTS.Tests.Infrastructure;
+using Xunit;
+
+namespace SharpTS.Tests.SharedTests;
+
+///
+/// Pins interpreter/compiled parity for the native-int modulo fast path (#928).
+///
+/// In compiled mode, counter % integerLiteral is emitted as a native int64 rem
+/// followed by conv.r8 (
+/// TryEmitIntegerCounterModulo) instead of an FP fmod on two doubles — the
+/// fmod is the dominant per-iteration cost in numeric write kernels. C# long % and
+/// JS % are both truncated (the result takes the dividend's sign), so the optimization is
+/// bit-identical to the double computation for every |dividend| ≤ 2^53 — the same range the
+/// int-counter representation already accepts (SHARPTS_INT_LOOP_COUNTER). The interpreter
+/// never takes the fast path, so each theory below asserts both modes agree, and every expected
+/// value is ground-truthed against Node (= the JS spec).
+///
+public class ModuloParityTests
+{
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void AscendingCounter_BasicDivisor(ExecutionMode mode)
+ {
+ var source = """
+ let r: string = "";
+ for (let i: number = 0; i < 10; i++) { r += (i % 3) + ","; }
+ console.log(r);
+ """;
+ Assert.Equal("0,1,2,0,1,2,0,1,2,0,\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void CounterPlusMinusLiteral_Dividend(ExecutionMode mode)
+ {
+ // `(i + k) % m` and `(i - k) % m` — the counter±literal dividend shapes the fast path recognizes.
+ var source = """
+ let r: string = "";
+ for (let i: number = 0; i < 8; i++) { r += ((i + 1) % 4) + ":" + ((i - 2) % 5) + " "; }
+ console.log(r);
+ """;
+ Assert.Equal("1:-2 2:-1 3:0 0:1 1:2 2:3 3:4 0:0 \n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void DescendingCounter_NegativeDividend_AndNegativeDivisor(ExecutionMode mode)
+ {
+ // Truncated remainder: the result takes the dividend's sign; a negative divisor does not
+ // change the sign. C# and JS agree on both.
+ var source = """
+ let r: string = "";
+ for (let i: number = 3; i > -4; i--) { r += (i % 3) + "/" + (i % -3) + " "; }
+ console.log(r);
+ """;
+ Assert.Equal("0/0 2/2 1/1 0/0 -1/-1 -2/-2 0/0 \n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void DivisorOne_AlwaysZero(ExecutionMode mode)
+ {
+ var source = """
+ let r: string = "";
+ for (let i: number = 0; i < 5; i++) { r += (i % 1) + ","; }
+ console.log(r);
+ """;
+ Assert.Equal("0,0,0,0,0,\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void DivisorZero_FallsBackToNaN_DoesNotThrow(ExecutionMode mode)
+ {
+ // `i % 0` is NaN in JS (fmod(x, 0)). The fast path explicitly declines divisor 0 so it never
+ // emits an int64 `rem` (which would be a DivideByZeroException) — it routes to the double path.
+ var source = """
+ let r: string = "";
+ for (let i: number = 0; i < 3; i++) { r += (i % 0) + ","; }
+ console.log(r);
+ """;
+ Assert.Equal("NaN,NaN,NaN,\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void NonCounterDividend_FallsBackToDoublePath(ExecutionMode mode)
+ {
+ // `(i * 2) % 5` — the dividend is a multiply, not a recognized counter expression, so the
+ // fast path declines and the double path computes it. Must still be correct.
+ var source = """
+ let r: string = "";
+ for (let i: number = 0; i < 6; i++) { r += ((i * 2) % 5) + ","; }
+ console.log(r);
+ """;
+ Assert.Equal("0,2,4,1,3,0,\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ModuloInFloat64WriteKernel(ExecutionMode mode)
+ {
+ // The real kernel shape: a modulo embedded in a mixed-double store expression into a
+ // Float64Array, then summed. Exercises native-int modulo feeding a double store.
+ var source = """
+ const a: Float64Array = new Float64Array(20);
+ for (let i: number = 0; i < 20; i++) { a[i] = i * 1.5 + (i % 7); }
+ let s: number = 0;
+ for (let i: number = 0; i < 20; i++) { s = s + a[i]; }
+ console.log(s);
+ """;
+ Assert.Equal("342\n", TestHarness.Run(source, mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
+ public void ModuloInInt32WriteKernel_ToInt32Truncation(ExecutionMode mode)
+ {
+ // Int32Array store path: the int64 modulo result feeds a store that ToInt32-truncates.
+ var source = """
+ const b: Int32Array = new Int32Array(15);
+ for (let i: number = 0; i < 15; i++) { b[i] = i * 3 - (i % 7); }
+ let r: string = "";
+ for (let i: number = 0; i < 15; i++) { r += b[i] + ","; }
+ console.log(r);
+ """;
+ Assert.Equal("0,2,4,6,8,10,12,21,23,25,27,29,31,33,42,\n", TestHarness.Run(source, mode));
+ }
+}