Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Compilation/ForLoopAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ public static class ForLoopAnalyzer
public static readonly bool IntegerCounterEnabled =
Environment.GetEnvironmentVariable("SHARPTS_INT_LOOP_COUNTER") != "0";

/// <summary>
/// #928: native int64 modulo for <c>counter % integerLiteral</c>. The integer loop
/// counter is already an <c>Int64</c> slot; emitting <c>i % 7</c> as an int64 <c>rem</c> (then
/// <c>conv.r8</c>) instead of an FP <c>fmod</c> on two doubles is bit-identical to JS truncated
/// remainder for every <c>|i| ≤ 2^53</c> — the same gate the counter representation already
/// accepts — and the <c>fmod</c> is the dominant per-iteration cost in write kernels (#928
/// measurement). On by default when the counter is on; kill with <c>SHARPTS_INT_MOD=0</c>.
/// </summary>
public static readonly bool IntegerModuloEnabled =
Environment.GetEnvironmentVariable("SHARPTS_INT_MOD") != "0";

/// <summary>
/// Identifies a for-loop counter eligible for the native <c>Int64</c> representation, or null.
/// Stricter than <see cref="Analyze"/>: requires an INTEGER-literal initializer, a pure
Expand Down
62 changes: 62 additions & 0 deletions Compilation/ILEmitter.Operators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -950,6 +958,60 @@ private static bool TryGetIntLiteralValue(Expr e, out long value)
return true;
}

/// <summary>
/// #928: emits <c>counter % integerLiteral</c> as a native int64 <c>rem</c> followed by
/// <c>conv.r8</c>, replacing the per-iteration FP <c>fmod</c> (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# <c>long %</c> and JS <c>%</c> are both truncated
/// (result takes the dividend's sign), so this is bit-identical to the double computation for every
/// <c>|dividend| ≤ 2^53</c> — the same range the int-counter representation already accepts. Divisor
/// 0 is left to the double path (<c>fmod(x,0)=NaN</c>), so no <c>DivideByZeroException</c> is risked.
/// Returns false (emitting nothing) when the shape does not qualify.
/// </summary>
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;
}

/// <summary>
/// Emits an integer-counter expression as a native <c>Int64</c> (no <c>conv.r8</c>): the counter
/// <c>i</c>, or <c>i ± intLiteral</c> / <c>intLiteral + i</c>. Companion to
/// <see cref="TryEmitIntegerCounterIndexI4"/> but leaves the value as Int64 on the stack. Returns
/// false (emitting nothing) when the shape is not a recognized integer-counter expression.
/// </summary>
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;
}

/// <summary>
/// Native int64 increment for an integer-counter local: <c>i++</c>/<c>i--</c> (postfix) or
/// <c>++i</c>/<c>--i</c> (prefix). Mutates the slot in place and leaves the postfix-old /
Expand Down
130 changes: 130 additions & 0 deletions SharpTS.Tests/SharedTests/ModuloParityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using SharpTS.Tests.Infrastructure;
using Xunit;

namespace SharpTS.Tests.SharedTests;

/// <summary>
/// Pins interpreter/compiled parity for the native-int modulo fast path (#928).
///
/// In compiled mode, <c>counter % integerLiteral</c> is emitted as a native int64 <c>rem</c>
/// followed by <c>conv.r8</c> (<see cref="SharpTS.Compilation.ILEmitter"/>
/// <c>TryEmitIntegerCounterModulo</c>) instead of an FP <c>fmod</c> on two doubles — the
/// <c>fmod</c> is the dominant per-iteration cost in numeric write kernels. C# <c>long %</c> and
/// JS <c>%</c> are both truncated (the result takes the dividend's sign), so the optimization is
/// bit-identical to the double computation for every <c>|dividend| ≤ 2^53</c> — the same range the
/// int-counter representation already accepts (<c>SHARPTS_INT_LOOP_COUNTER</c>). 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).
/// </summary>
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));
}
}
Loading