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)); + } +}